{"error":"invalid_tools","details":[...]} and
node graphs return
{"error":"invalid_flow","details":[...]}, each with every offending field
path. Flow generation uses the same shape
for flow_generation_failed. Handle those shapes when saving agent tools or a
flow, or generating one.
{
"error": {
"code": "invalid_request",
"message": "to must be an E.164 phone number, got '9876543210'"
}
}
| Field | Description |
|---|---|
code | Stable, snake_case, machine-readable. Branch on this. |
message | Human-readable, written for a developer reading a log. It is not stable, so never parse it or show it to an end user verbatim. |
Status codes
| Status | Meaning | Retry? |
|---|---|---|
400 | The request is malformed or a field is invalid. | No. Fix the request. |
401 | Missing, malformed or unknown key, or an invalid token on a browser call’s media link, a call’s events link or a speech stream. | No. |
402 | Wallet cannot cover the call, or is empty when you generate a flow or transcribe a recording. | After topping up. |
403 | The key is valid but revoked, the call is not covered by your company’s plan, or it asks for recording on a workspace with recording turned off. | No. |
404 | No such resource in your workspace, including one that exists in someone else’s. | No. |
413 | The request body, or a file you sent by link, is over the endpoint’s limit, such as an offline transcription upload over 45 MB. | No. Send a smaller request. |
415 | Speech to text could not decode the audio file you sent. | No. Send WAV, a common compressed format, or raw audio with encoding and sample_rate. |
422 | The request was valid but the work could not produce a usable result, such as a flow generation that stayed invalid or a recording with no conversation in it. | Not as is. Change the request. |
409 | State conflict: a request with the same Idempotency-Key still in flight, or an operation the current state forbids (such as controlling a call that is not in progress, a call option a node-based agent does not take, or starting a campaign whose calling window has closed for good). | No. |
426 | A plain HTTP request reached a WebSocket endpoint, such as the TTS stream. | No. Connect with a WebSocket client. |
429 | Request-rate limit, or no capacity for the request (at_capacity). Reaching your call concurrency limit does not return 429: those calls queue. | Yes, with backoff. |
500 | Our fault. | Yes, with backoff. |
501 | The feature exists in the contract but is not live yet. | No. |
502 | A model behind the request failed: the speech engine or the language model refused, returned nothing or stalled (upstream_error). Nothing was charged. | Yes. |
503 | Temporarily unavailable (deploy, capacity), or a feature that is not provisioned for your workspace. | Yes for fleet_offline and upstream_unavailable. No for campaigns_unavailable and transcription_unavailable, which clear only when the feature is provisioned. |
504 | Speech to text over HTTP did not get the transcript back in time (upstream_timeout). Nothing was charged. | Yes. |
Error codes
error.code | Status | When | Fix |
|---|---|---|---|
invalid_request | 400 | Missing required field, wrong type, to not E.164, max_duration_secs out of range, unknown voice_id, malformed JSON. | Read message; it names the field. |
invalid_flow | 400 | A node graph breaks a rule (including a node’s data.tools naming a tool the agent doesn’t have), a node-based agent has end_call enabled, or your tier’s model cannot run flows. Sent as {"error":"invalid_flow","details":[{"path","message"}]}, not in the envelope. | Fix every listed path. See node-based agent errors. |
unauthorized | 401 | No Authorization header, not Bearer -prefixed, or the key is unknown. | Check the header. A trailing newline from cat-ing a key file is the usual culprit. |
invalid_token | 401 | The token on a browser call’s media link, or on any call’s events link, is unknown, expired, already used (the media link is single use), or belongs to another call. On a TTS stream, the stream token is unknown, older than 60 seconds or already used. | Use the links exactly as returned. For audio, create a new call, because the media link cannot be reused. For live events, your API key opens the same stream. For a TTS stream, mint a new token for each connection. |
upgrade_required | 426 | A plain HTTP request reached a WebSocket endpoint, such as the TTS stream. | Connect with a WebSocket client (wss://). |
forbidden | 403 | The key was revoked. | Get a new key. |
production_workspace_required | 403 | A key from a workspace that has not gone live was sent to the production address, https://prod.voice.miraiminds.co. | Use https://sandbox.voice.miraiminds.co, or complete Go live in the console first. |
recording_disabled | 403 | recording_enabled: true on a workspace whose recording is turned off. Nothing was created. | Leave recording_enabled out, or send false. |
contract_unavailable | 403 | The call is not covered by an active production contract: tier is not your company’s plan, or the company has not finished going live. Nothing was dialled. | Leave tier out. Calls default to your plan’s tier. See setting the tier. |
insufficient_balance | 402 | The wallet failed the pre-dial balance check, or it is empty when you generate a flow, transcribe a recording for a flow or start an offline transcription, or it is empty (counting usage not yet charged) when you call the language model. No call was placed, no model ran and nothing was charged. Inside a running campaign, the same condition is not an error: the campaign pauses with pause_reason: "insufficient_balance". | Top up and retry with the same Idempotency-Key. |
not_found | 404 | Unknown agt_/call_/cmp_/trn_ ID, a soft-deleted agent, a number not on the do-not-call list, a transcript or recording that does not exist, or an ID that belongs to another workspace. | Check the ID. For artifacts, check transcript_available / recording_available on the call. |
duplicate_call | 409 | A request with this Idempotency-Key is still being processed; when the original completes, the same key replays its stored response. | Not an error if you are retrying. No second call was placed. |
conflict | 409 | The operation is illegal in the current state: aborting a call that has already ended, editing or starting a campaign that is play, adding contacts to one that is stopped/completed, or resuming one with no running dialler. | Read the resource’s status first. To resume a paused campaign that has no dialler, use POST /start. |
number_retired | 409 | A call, campaign or agent names a phone number that you retired, or you tried to rest one. | Resume the number, or choose another. |
number_resting | 409 | POST /v2/calls names a number that is resting or cooling. The message gives the time it is back. | Wait, resume it, or choose another. A campaign on it waits instead. |
pool_busy | 409 | POST /v2/calls names a pool whose numbers are all resting or at their hourly pace. | Honour Retry-After, or raise the pool’s pace. |
pool_unavailable | 409 | The pool has no number that can place a call. | Add a ready number to it, or choose another. |
pool_in_use | 409 | You deleted a pool that a campaign or agent calls from. The message names them. | Move them to another pool or number, then delete. |
window_closed | 409 | A campaign start or resume was refused because its calling window can never open again: no slot opens on or before end_date in the campaign’s timezone. The usual cause is start_date and end_date both set to today, with the last slot already over. Nothing was dialled and the campaign kept its status. | Extend end_date or widen slots with PATCH, then start or resume again. |
rate_limited | 429 | Request rate exceeded, or your queue is full (500 calls accepted and not yet ended), or your workspace has used its flow generations or recording transcriptions for the hour, or the generator or transcriber is busy. On offline transcription, it means your workspace has started 120 jobs in the past hour, or uploads are busy for a few seconds. Being at your concurrency ceiling does not produce this: those calls queue. error.message says which limit you hit. | Honour Retry-After and back off. See Limits. |
at_capacity | 429 | No capacity for this request. On calls, every line on the deployment is busy; that ceiling is fleet-wide, not your workspace’s. On text to speech it also means your workspace already has as many requests in flight as its limit allows (10 by default), or, when you open a TTS stream, that it already has 20 sockets open. On offline transcription, your workspace already has 20 unfinished jobs, and Retry-After is 30 seconds. Nothing was charged in any of these cases. | Honour Retry-After and retry. |
call_not_active | 409 | A live control was sent to a call that is not in progress: still queued (the page has not connected its audio) or already ended. | Wait for call.started; after a terminal event there is nothing left to control. |
unsupported_channel | 400 | A live control was sent to a call that is not a browser call. | Live control is for channel: "web" calls. To stop a phone call, use POST /abort. |
flow_agent_voice_only | 409 | A text chat (channel: "text") named a node-based agent. Nothing was created. | Use phone or web, or a single-prompt agent for text. |
flow_generation_failed | 422 | Flow generation could not produce a valid graph. Sent as {"error":"flow_generation_failed","details":[{"path","message"}]}, not in the envelope, with the generation’s id, model, usage and cost. Nothing was saved; the tokens used are charged. | Rephrase the brief (name each step and branch), try another model, or fix the graph by hand. |
transcription_failed | 422 | Transcribing a recording for a flow found no conversation in it. The body carries the transcription’s id, model, duration_secs, usage and cost beside error: the model listened to the audio, so it is charged. An offline transcription uses the same code differently: it is the error.code of a failed job, not an HTTP error, and that job is free. | Check that the file is a phone call with speech. |
request_too_large | 413 | The request is over the endpoint’s limit: a recording to transcribe for a flow or a speech-to-text clip over 25 MB, or an offline transcription upload over 45 MB or file at url over 200 MB. Nothing was charged. | Send a smaller file, such as a mono mp3. For offline transcription, send a file over 45 MB by link. |
flow_first_message_unsupported | 409 | A call sent first_message for a node-based agent. Nothing was created. | Leave first_message out. The start node’s greeting opens the call; personalise it with variables. |
flow_system_prompt_unsupported | 409 | A call sent a per-call system_prompt for a node-based agent. Nothing was created. | Leave system_prompt out. The nodes’ prompts run the call; personalise them with variables. |
tier_unavailable | 501 | tier names a tier that is not enabled on this deployment. | Leave tier out. Calls default to your plan’s tier. See tiers. |
campaigns_unavailable | 503 | Campaigns are not enabled for your workspace. Every /v2/campaigns and /v2/dnc route answers this; everything else keeps working. | Your request is not at fault, and retrying will not clear it. Ask your Mirai contact to enable campaigns. |
transcription_unavailable | 503 | Offline transcription is not available on this deployment. Every /v2/transcriptions route answers this; everything else keeps working. Nothing was charged. | Retrying will not clear it. Ask your Mirai contact. |
upstream_unavailable | 503 | A service the request depends on is briefly unavailable, such as the flow generator’s or the transcriber’s model. Nothing was saved. A flow generation whose model had already answered is charged for what it did, and the body carries id, model, usage and cost. A transcription is never charged on a 503. A failed offline transcription job can also carry this code as its error.code: the job failed because of a problem on our side, not with the recording, and it is free. | Honour Retry-After and retry. For a failed job, send the recording again in a few minutes. |
format_unavailable | 503 | A text-to-speech request asked for mp3, opus, aac or flac and our server cannot encode it right now. Nothing was charged. | Ask for pcm or wav, or retry later. |
fleet_offline | 503 | Speech capacity is offline, so a campaign start was refused. Nothing was charged and the campaign stayed where it was. | Honour Retry-After (300s) and start it again. |
invalid_config | 400 | A speech-to-text field or socket query parameter is missing, unknown or out of range, including a language outside the ten we transcribe or auto. On an open socket, a config.update that changed nothing. message names the field. | Fix the field and send again. |
model_not_found | 404 | model is not one we serve: mira-tts for text to speech, mira-stt for speech to text, or one of their other names. | Send the model name. |
audio_too_long | 413 | A speech-to-text clip longer than 120 seconds. Nothing was charged. An offline transcription job over 2 hours also fails with this code, free. | Send long recordings to offline transcription. |
unsupported_media | 415 | We could not decode the file sent to speech to text. Nothing was charged. | Send WAV, a common compressed format, or raw audio with encoding and sample_rate. |
unavailable | 503 | The speech-to-text recogniser is briefly unreachable or restarting, or a speech socket reached a server that is restarting. Nothing was charged. | Honour Retry-After and retry. |
maintenance_window | 503 | Rare. A speech API request arrived while that API was down for planned maintenance. The speech APIs are otherwise available around the clock. Nothing was charged. | Retry after Retry-After, which counts the seconds until the API is back. |
billing_unavailable | 503 | We could not read your wallet, so a speech-to-text session was not opened. | Retry in a couple of seconds. |
upstream_error | 502 | The model behind the request failed. On text to speech, the engine refused, returned no audio or stalled. On the language model, the model was unavailable or returned nothing usable, and the body carries a correlation_id. Nothing was charged. | Retry. Quote X-Request-Id or correlation_id if you ask us about it. |
upstream_timeout | 504 | Speech to text over HTTP did not get the transcript back in time. Nothing was charged. | Retry. For a long clip, use offline transcription. |
internal | 500 | Our bug. It has already raised an alert on our side. | Retry with backoff; if it persists, send us the call_id. |
error.code is an open enum. We add new codes without a version bump, so
always keep a default branch keyed on the HTTP status.Foreign IDs are
404, never 403An agt_, call_, cmp_ or trn_ ID that exists in another workspace is
answered exactly as an ID that never existed: 404 not_found. Any other answer would be an
enumeration oracle, letting someone probe which IDs are real on the platform by
watching the status code change. 403 never means “someone else’s ID”. It means
your own key has been revoked, or your workspace is not allowed what the request
asks for (contract_unavailable, recording_disabled).Handling errors
To handle every error, branch on the status first and then on the code.- Python
- Node.js
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")
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
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);
}
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,504and network errors. Use exponential backoff with jitter, and honourRetry-Afterwhen present. - Never retry
400,401,403,404,501. The same request will fail the same way.503 campaigns_unavailableand503 transcription_unavailablebelong in this group too, because they clear only when the feature is provisioned. 402is retryable only after a top-up, not on a timer.- Always send
Idempotency-KeyonPOST /v2/calls. A timeout does not tell you whether the phone rang, and without the key, your retry places a second call to a real person.
What a 409 duplicate_call actually means
A request with this Idempotency-Key is still being processed. This only
happens in a narrow race and is not the normal retry path. We cannot replay a
response that has not been produced yet, and we must not place a second call,
so we return this error instead.
When the original request completes, the same key replays its stored response,
so a retry a moment later returns the original 202 and the original call_id.
In both cases no second call is placed. For a retry this is usually a
success, so log it and move on instead of showing it to your users as an
error.