Calls
Place outbound calls and read their results.
A call is a single phone interaction placed or answered by one of your agents. Place calls with POST /v1/calls, then read their state, transcript, and extracted data back.
The call object (list shape):
{
"id": "call_…",
"contactId": "con_…",
"campaignId": null,
"agentId": "agt_…",
"channel": "voice",
"direction": "outbound",
"status": "completed",
"state": "ended",
"outcome": "call_initiated",
"toNumber": "+919812345678",
"fromNumber": "+918050000000",
"answered": true,
"answeredAt": "2026-08-20T12:00:08Z",
"endCause": "caller_hangup",
"startedAt": "2026-08-20T12:00:00Z",
"endedAt": "2026-08-20T12:01:36Z",
"durationSeconds": 88,
"metadata": { "orderId": "ORD-42" }
}
metadata- themetadatayou passed when placing the call ({}if none).state- where the call is now:ringing(placed, not answered yet),in_progress(answered),ended, orblocked(refused at placement). Use this, notstatus, to tell a live call from a finished one.answered/answeredAt- whether (and when) the callee picked up.endCause- why it ended (for examplecaller_hangup,agent_ended,no_answer), once known.durationSeconds- talk time (answered → ended), set when the call ends;nullwhile it’s still going.endedAtis likewisenulluntil the call ends.toNumber- the number dialled;fromNumber- your number it went out on.
Only AI voice calls are returned - never chat sessions, test calls, or calls placed by a person from the console.
Place a call
POST /v1/calls · scope calls:write · rate limited
curl -X POST https://api.oyehello.ai/v1/calls \
-H "Authorization: Bearer ohk_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-42-call-1" \
-d '{
"agentId": "agt_…",
"toNumber": "+919812345678",
"externalRef": "your-record-id",
"variables": { "name": "Nalin", "amount": "4999" }
}'
Body
| Field | Type | Required | Notes |
|---|---|---|---|
agentId |
string | yes | A published, outbound call agent. |
toNumber |
string | yes | Destination - an Indian +91 subscriber number. Local formats (98123 45678) are normalized. |
fromNumber |
string | no | Caller ID - must be an active number you own (400 from_number_not_owned otherwise). Defaults to your first active number. |
externalRef |
string | no | Your own stable id (1–120 chars). Reuses the same contact across calls. Omitted → the workspace’s contact for toNumber is used (one number = one contact). |
variables |
object | no | String values for the agent’s {variables} on this call. They override the values saved on the contact for this call only (a new contact starts with them; an existing contact’s saved data is not changed). A variable the agent’s flow requires must be supplied here or already saved on the contact. Limits: up to 50 variables; names of letters, digits and _ (not starting with a digit, at most 64 characters); values are strings of up to 120 characters (the most the agent’s prompt takes from one variable), numbers or booleans (sent as text); 16 KB in all. These limits will be enforced in a future release; today over-limit values are truncated and reported in warnings (longer values are cut to 120 characters; other limits are only reported). |
metadata |
object | no | Your own data, stored on the call and returned on GET /v1/calls and in every call webhook. The agent never sees it. Up to 20 keys (1–40 chars); values are strings (≤500 chars), numbers, booleans or null; 4 KB in all. Erasing the contact clears it. |
Idempotency
Send an Idempotency-Key header (up to 255 characters) to make retries safe. A retry with the same key and the same body returns the first response instead of placing a second call. Keys last 24 hours and are scoped to your workspace.
| Status | Code | Meaning |
|---|---|---|
409 |
idempotency_in_progress |
The first request with this key is still running. |
422 |
idempotency_key_reused |
The key was already used for a different request. |
A request that’s refused (any 4xx) frees its key, so you can fix the problem and retry with the same key.
Response - 200
This endpoint returns a bare object (no data wrapper):
{ "placed": true, "interactionId": "call_…", "providerCallId": "prov_…" }
When something in variables went over a limit, the response also carries warnings - for example
"warnings": [{ "code": "variable_truncated", "field": "address", "message": "value cut to 120 characters …" }].
Codes: variable_truncated (that value was cut to 120 characters) and variables_over_limit (a name, the count or the
total size is over a limit; accepted for now). warnings is absent when nothing applied.
Use interactionId with GET /v1/calls/:id to follow the call from ringing to ended. The call runs the agent’s full compliance pipeline exactly as a campaign dial does.
Gates & errors
| Status | Error / code | Cause |
|---|---|---|
404 |
agent not found |
Unknown agentId. |
422 |
agent_not_published |
The agent is a draft (or has no published flow). |
422 |
agent_not_call / agent_not_outbound |
The agent is a chat agent, or an inbound agent. |
400 |
invalid_variables |
A variables value is not a string, number or boolean. |
422 |
missing_required_variables |
The agent’s flow needs variables you didn’t supply (meta.missing lists them). |
422 |
flow_not_ready |
Something the agent’s flow uses (a transfer number, an integration) is disconnected. |
403 |
workspace suspended |
The workspace is suspended. |
403 |
workspace must be verified (KYB) before placing calls |
Complete KYB first. |
400 |
toNumber must be a valid Indian (+91) subscriber number |
Bad destination. |
400 |
from_number_not_owned |
fromNumber isn’t an active number you own. |
400 |
buy a phone number before placing calls |
No owned number to dial from. |
402 |
insufficient wallet balance - top up to place a call |
Wallet can’t cover the call (in-flight calls counted). |
422 |
call blocked by compliance: <reason> |
DND, opt-out, quiet-hours, or frequency cap. |
429 |
concurrent-call limit reached … / platform is at capacity … |
Too many calls in flight - retry shortly. |
503 |
India-resident voice providers are not configured … |
Workspace requires India-resident providers that aren’t set up. |
502 |
call could not be placed |
Provider/dispatch failure. |
List calls
GET /v1/calls · scope calls:read
Newest first.
Query
| Param | Type | Notes |
|---|---|---|
limit |
int | 1–200, default 50. |
cursor |
string | The nextCursor from the previous page. |
state |
string | ringing, in_progress, ended, or blocked. |
agentId / campaignId / contactId |
string | Only calls for that agent / campaign / contact. |
direction |
string | outbound or inbound. |
from / to |
ISO-8601 | Calls that started at or after from, and before to. |
status |
string | The raw stored status (prefer state). |
Response - 200
{ "data": [ { "id": "call_…", "state": "ended", … } ], "nextCursor": "MjAyNi0…" }
nextCursor is null on the last page. An invalid cursor, state, direction, from or to is a 400.
Get a call
GET /v1/calls/:id · scope calls:read
Returns the full call - the list shape plus the summary, transcript, extracted fields, and a recording URL:
{
"data": {
"id": "call_…",
"state": "ended",
"outcome": "interested",
"durationSeconds": 88,
"summary": "Customer agreed to a callback on Tuesday.",
"transcript": [
{ "role": "agent", "text": "Hi, this is Riya from OyeHello…" },
{ "role": "customer", "text": "Yes, go ahead." }
],
"extracted": { "callback": "Tuesday" },
"recordingUrl": "https://…signed…",
"fullTranscript": {
"status": "done",
"turns": [ { "speaker": "agent", "text": "Hi, this is Asha…", "start": 0.4 } ]
},
"connectorPushes": [
{ "provider": "hubspot", "status": "success", "error": null, "createdAt": "2026-08-20T12:02:10Z" }
]
}
}
summary- a one-line summary of the call, once it’s been analyzed (elsenull).transcript- the diarized conversation ([]if none).extracted- the structured fields the agent captured ({}if none).recordingUrl- a signed playback URL (valid for a few minutes) if a recording exists, elsenull. It is alsonullwhile the recording is still processing, and when a failed recording has no audio.fullTranscript- the transcript made from the call recording, split by speaker and including any human the call was transferred to.statusisnull(not generated for this workspace),pending,processing,doneorfailed;turnsis set once it’sdone. It’s produced a few minutes after the call ends.connectorPushes- what happened when the call’s outcome was sent to your connected apps (CRM, Sheets, …) after the call: one entry per attempt, newest first,statussuccess,failedorskipped.
Unknown id → 404 { "error": "call not found" }.
Hang up a call
POST /v1/calls/:id/hangup · scope calls:write
Ends a live AI call, whether it’s still ringing or already connected. The call then finishes the usual way: it’s billed for its talk time, its endCause becomes api_hangup, and the call.completed (or, if it never connected, call.failed) webhook fires.
Response - 202
{ "data": { "id": "call_…", "state": "ending" } }
Hanging up a call you’ve already asked to end returns 202 again.
| Status | Code | Meaning |
|---|---|---|
404 |
- | No such call in this workspace. |
409 |
call_not_active |
The call has already ended, was blocked, or was dialled by a person from the console. |
502 |
hangup_failed |
The call couldn’t be ended right now. It’s still live, so retry. |
503 |
hangup_unavailable |
Hanging up isn’t available on this deployment. |
Call analytics
GET /v1/analytics/calls · scope calls:read
The console’s call analytics for a period. Query: from / to (ISO-8601; calls that started in the range), and optionally agentId or campaignId.
{
"data": {
"totals": {
"calls": 1240, "answered": 812, "connectRate": 0.655, "peopleReached": 790, "contactsTargeted": 1102,
"talkSecondsTotal": 76140, "avgTalkSeconds": 94, "talkSecondsP50": 71, "talkSecondsP90": 212, "peakConcurrency": 18,
"blockedCalls": 64, "declinedCalls": 3
},
"byOutcome": { "interested": 212, "callback": 88, "not_interested": 301 },
"byEndCause": { "caller_hangup": 540, "agent_ended": 250, "no_answer": 300 },
"perDay": [ { "day": "2026-09-20", "calls": 140, "answered": 92, "talkSeconds": 8410 } ],
"agents": [
{ "agentId": "agt_…", "name": "Delivery reminders", "calls": 900, "connectRate": 0.66, "avgTalkSeconds": 97, "inbound": 0, "outbound": 900, "sentiment": "neutral" },
{ "agentId": null, "name": null, "calls": 12, "connectRate": 0.5, "avgTalkSeconds": 60, "inbound": 12, "outbound": 0, "sentiment": null }
],
"byLanguage": [ { "language": "hi", "count": 600 } ],
"sentiment": [ { "sentiment": "positive", "count": 310 } ]
}
}
- Only AI calls count. Test calls, chats, and calls a teammate placed by hand are excluded.
- Only calls that were actually dialled count toward
calls,answered,connectRateand every other figure. Two kinds are reported on their own instead:blockedCalls: campaign dials stopped by a compliance check (DND, calling frequency and similar). They were never dialled.declinedCalls: inbound calls that were refused, for example because no agent was set up for the number.
- A call that was dialled but failed at the phone network still counts as a call that wasn’t answered.
agentshas one row per agent. Calls with no agent come last, in a single row withagentIdandnameset tonull. The rows add up tototals.calls.answeredmeans the callee actually picked up. Durations are talk time (pickup to hang-up).peopleReachedcounts distinct people who answered;contactsTargetedcounts distinct contacts dialled.- Spend isn’t included. Use
GET /v1/wallet/transactions(billing:read). 403 plan_feature_lockedif analytics are turned off for your workspace.
Schedule a call
POST /v1/scheduled-calls · scope calls:write
The same body as placing a call, plus scheduledAt, and the call is placed then instead of now:
{ "agentId": "agt_…", "toNumber": "+919812345678", "scheduledAt": "2026-10-02T11:00:00+05:30", "variables": { "due": "12 Oct" }, "metadata": { "orderId": "ORD-42" } }
scheduledAt- ISO-8601 with a time zone. From now up to 30 days ahead (400 scheduled_at_in_past/scheduled_at_too_far).variableshas the same limits as placing a call: over-limit values are truncated (and stored truncated) and reported inwarningsnext todata.- The agent,
toNumberandfromNumberare checked now, with the same errors as placing a call. Everything else (wallet, calling hours, DND, frequency) is checked when the call is placed.
Returns 201 with the scheduled call:
{
"data": {
"id": "scall_…", "agentId": "agt_…", "status": "scheduled", "scheduledAt": "2026-10-02T05:30:00.000Z",
"nextAttemptAt": "2026-10-02T05:30:00.000Z", "attempts": 0, "callId": null, "reason": null,
"toNumber": "+919812345678", "fromNumber": null, "externalRef": null, "variables": { "due": "12 Oct" }, "metadata": { "orderId": "ORD-42" },
"createdAt": "…"
}
}
When it’s due, the call is placed through exactly the same checks as POST /v1/calls:
| What happens at call time | Result |
|---|---|
| The call goes through | status: "placed", and callId is the call. |
| Outside calling hours | It waits for the next calling window (nextAttemptAt moves; reason says why). |
| Low wallet, too many calls in progress, provider error | Retried after 15 minutes. |
| The number is on DND or opted out | status: "cancelled", not retried. |
| Anything else (agent unpublished, missing variables, …) | status: "failed", with the error in reason. |
Every try counts toward a limit of 3. If the third try doesn’t place the call, it’s failed.
A scheduled call is never dialled twice. If the system can’t be sure whether a dial went out, the scheduled call is marked failed with a reason saying so, and it is not retried. Check the contact’s calls before scheduling it again. Once placed, the call sends the usual webhooks (with your metadata).
List scheduled calls
GET /v1/scheduled-calls · scope calls:read
Newest first. Query: status (scheduled, placed, failed, cancelled), limit (1–200, default 50), offset.
Get a scheduled call
GET /v1/scheduled-calls/:id · scope calls:read
Cancel a scheduled call
DELETE /v1/scheduled-calls/:id · scope calls:write
Returns the call with status: "cancelled". 409 scheduled_call_not_cancellable once it has been placed, failed, or is being dialled at that moment.
Next: Agents →