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 - the metadata you passed when placing the call ({} if none).
  • state - where the call is now: ringing (placed, not answered yet), in_progress (answered), ended, or blocked (refused at placement). Use this, not status, to tell a live call from a finished one.
  • answered / answeredAt - whether (and when) the callee picked up.
  • endCause - why it ended (for example caller_hangup, agent_ended, no_answer), once known.
  • durationSeconds - talk time (answered → ended), set when the call ends; null while it’s still going. endedAt is likewise null until 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 (else null).
  • 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, else null. It is also null while 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. status is null (not generated for this workspace), pending, processing, done or failed; turns is set once it’s done. 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, status success, failed or skipped.

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, connectRate and 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.
  • agents has one row per agent. Calls with no agent come last, in a single row with agentId and name set to null. The rows add up to totals.calls.
  • answered means the callee actually picked up. Durations are talk time (pickup to hang-up).
  • peopleReached counts distinct people who answered; contactsTargeted counts distinct contacts dialled.
  • Spend isn’t included. Use GET /v1/wallet/transactions (billing:read).
  • 403 plan_feature_locked if 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).
  • variables has the same limits as placing a call: over-limit values are truncated (and stored truncated) and reported in warnings next to data.
  • The agent, toNumber and fromNumber are 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 →