Console

Shravan API

Put an AI voice agent
on any phone line, from code.

The Shravan API places and manages AI voice calls in Hindi, English and other Indic languages. Create an agent, point it at a number, and get the transcript, outcome and cost back — as a webhook or a GET.

BASE URL https://api.shravan-ai.tech/v1
Everything below is available on TEST keys with zero cost: a sk_test_ key runs a realistic sandbox call — answered, completed with a transcript, analyzed — and fires the same webhooks a live call would. Flip to LIVE when you are ready to ring a real phone.

Quickstart

Three requests from zero to a completed call.

1. Create a key

In the console, open Developers → Create key. Choose Test first. The secret is shown once; keep it in your secrets manager. Every key belongs to exactly one business.

2. Find your agent

Every flow you have built in the console is already an agent. List them:

curl https://api.shravan-ai.tech/v1/agents \
  -H "Authorization: Bearer sk_test_..."

3. Place a call

curl https://api.shravan-ai.tech/v1/calls \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: lead-8891" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "agent_1a2b3c4d5e6f7a8b",
    "to": "+919876543210",
    "contact_name": "Priya",
    "metadata": {"crm_id": "L-42"}
  }'

Poll GET /v1/calls/{id} or, better, register a webhook and receive call.completed and call.analyzed with the transcript, outcome and cost.

Authentication

Send your key as a bearer token on every request. Keys are created and revoked in the console; they carry scopes and are bound to one business.

Authorization: Bearer sk_live_9fA1...k3Zq
ScopeGrants
calls:writeCreate calls, hang up. Implies calls:read.
calls:readList and read calls and transcripts.
agents:write / agents:readManage / read agents.
knowledge:write / knowledge:readManage / read the knowledge base.
webhooks:manageCreate, edit, test and delete webhook endpoints.
usage:readRead usage and the rate card.

Keys can also carry an expiry and an IP allowlist. Rotating a key issues a new secret and, optionally, keeps the old one valid for a grace period so you can deploy without a hard cutover. GET /v1/me returns what a key is.

Test mode

Two kinds of key, one API:

KeyBehaviour
sk_test_Never dials a phone, never touches your wallet. POST /v1/calls creates a sandbox call that is answered after ~4 s, completes after the duration you choose with a canned transcript in the agent's language, and is analyzed a few seconds later — firing the same webhooks as a live call with "livemode": false. Test keys see only test calls.
sk_live_Places a real call through the business's telephony and bills the wallet per minute at the API rate. Live keys see only live calls.

Sandbox-only fields on POST /v1/calls: sandbox_scenario (completed default, no_answer, busy, failed) and sandbox_duration_seconds (5–120). Sending them with a live key is a 400. Agents and knowledge are shared between modes.

Errors

Errors use conventional HTTP status codes and one JSON envelope. Branch on type; show message to a human; log code.

{
  "error": {
    "type": "insufficient_funds_error",
    "code": "insufficient_funds",
    "message": "Wallet balance is too low to place this call. Top up and retry.",
    "call_id": "0d1c...c9"
  }
}
StatustypeWhen
400invalid_request_errorMalformed or missing parameter; param names it.
401authentication_errorMissing, invalid, revoked or expired key.
402insufficient_funds_errorWallet cannot fund the call.
403permission_errorMissing scope, or IP not on the key's allowlist.
404not_found_errorNo such resource in this business.
409idempotency_errorIdempotency-Key reused with a different body, or still in progress; also call_already_ended.
429rate_limit_errorPer-key request limit or per-business call pacing; honour Retry-After.
502telephony_errorThe carrier rejected the request.

Every response carries X-Request-Id; include it when you write to support.

Pagination

List endpoints return newest-first pages with an opaque cursor.

GET /v1/calls?limit=50
{ "object": "list", "data": [...], "has_more": true, "next_cursor": "aWQ6MTIz" }

GET /v1/calls?limit=50&cursor=aWQ6MTIz

limit is 1–100 (default 20). Cursors are stable under new inserts.

Idempotency

Network retries must not ring someone twice. Send any unique string in Idempotency-Key on POST /v1/calls: a retry with the same key and body returns the original response (with header Idempotent-Replayed: true) instead of placing a second call. Keys are scoped to your business and expire after 24 hours. Same key, different body → 409.

Rate limits

Each key has its own request budget (default 60 requests/minute, adjustable per key in the console). Exceeding it returns 429 with Retry-After and X-RateLimit-Limit. Separately, outbound call pacing per business (concurrent calls and starts per minute) protects call quality; a paced-out call is a 429 with code: call_rate_limited.

Agents

An agent is a conversation flow plus the voice it speaks with. Flows built in the console are agents automatically; you can also create agents from a flow document.

GET /v1/agents
POST /v1/agents
GET /v1/agents/{id}
PATCH /v1/agents/{id}
DELETE /v1/agents/{id}
{
  "id": "agent_1a2b3c4d5e6f7a8b",
  "object": "agent",
  "name": "Counselling booking (Hindi)",
  "flow_id": "aarohan_learning_counselling_booking",
  "language": "hi",
  "voice": { "tts_provider": "sarvam", "tts_voice": "priya" },
  "llm": null, "stt": null, "telephony_provider": null,
  "metadata": {}, "is_active": true,
  "created_at": "2026-08-17T10:20:00+00:00"
}
FieldNotes
flow_id or flow_jsonReference an existing flow, or send a full flow document to compile and save (returns 400 flow_invalid with the compiler's message).
languageDefault call language: hi, en, kn, ta, te, mr, bn, gu, ml, pa, od. Null inherits the business default.
voiceOptional { "tts_provider", "tts_model", "tts_voice" }. Providers: sarvam (default) or elevenlabs. Sarvam models: bulbul:v3 (default), bulbul:v2. Voice names belong to a model generation — bulbul:v3 voices: shubh (default), priya, ritu, neha, pooja, rahul, aditya, simran, kavya, ishita, shreya, rohan, amit, dev, varun and more; bulbul:v2 voices: anushka, abhilash, manisha, vidya, arya, karun, hitesh. A voice the model does not know is answered with the model's default voice (and logged) rather than a failed call. Null inherits the business default.
llm, sttOptional { "llm_model" } / { "stt_provider" } overrides. Null inherits the business default.
telephony_providertwilio or plivo; null uses the business setting.

Overrides are applied on the live call the moment it is answered — every sentence the agent speaks uses the agent's language, provider, model and voice. What a call actually ran with is frozen on the call object as runtime_config, and the agent as it was when the call was placed as agent_snapshot, so editing an agent later never rewrites history (and never changes a call already in progress).

DELETE deactivates the agent (past calls keep referencing it); it can no longer take calls.

Calls

POST /v1/calls
GET /v1/calls
GET /v1/calls/{id}
GET /v1/calls/{id}/transcript
POST /v1/calls/{id}/hangup

Create

ParameterNotes
agent_idRequired. An active agent in your business.
toRequired. Destination number; E.164 (+91...) recommended.
contact_nameOptional. Used by the agent to address the person.
metadataOptional. Up to 20 scalar key/values (≤4 KB) echoed on every read and webhook — your CRM id, campaign tag, anything.
sandbox_scenario, sandbox_duration_secondsTest keys only.

The call object

{
  "id": "0d1cd7d2-...-c9",
  "object": "call",
  "livemode": true,
  "business_id": "aarohan_learning",
  "agent_id": "agent_1a2b3c4d5e6f7a8b",
  "direction": "outbound",
  "to": "+919876543210",
  "from": "+15551234567",
  "status": "completed",            // queued | initiated | in_progress | completed | failed
  "failure_reason": null,           // e.g. no_answer, busy, insufficient_wallet_balance
  "started_at": "2026-08-17T10:21:04+00:00",
  "ended_at":   "2026-08-17T10:22:38+00:00",
  "duration_seconds": 94,
  "disposition": "appointment_booked",
  "summary": "Parent confirmed a counselling slot for Thursday 4 pm.",
  "sentiment": "positive",
  "extracted_data": { "slot": "Thu 16:00", "student_name": "Harsh" },
  "recording_url": "https://api.shravan-ai.tech/v1/calls/0d1cd7d2-...-c9/recording",   // null unless recorded + stored
  "recording": { "status": "stored", "duration_seconds": 94, "provider": "twilio" },   // not_recorded | pending | stored | failed
  "cost": { "amount_paise": 752, "currency": "INR", "rate_paise_per_min": 480 },
  "metadata": { "crm_id": "L-42" },
  "runtime_config": {               // frozen when the call is answered
    "language": "hi", "tts_provider": "sarvam", "tts_model": "bulbul:v3", "tts_voice": "priya",
    "llm_model": "openai/gpt-oss-20b", "stt_provider": "deepgram",
    "flow_id": "aarohan_learning_counselling_booking", "flow_version": 7, "agent_id": "agent_1a2b3c4d5e6f7a8b"
  },
  "agent_snapshot": { "agent_id": "agent_1a2b...", "name": "Counselling booking (Hindi)", "language": "hi",
                      "voice": { "tts_voice": "priya" }, "llm": null, "stt": null, "agent_updated_at": "..." },
  "created_at": "2026-08-17T10:21:03+00:00",
  "updated_at": "2026-08-17T10:22:45+00:00"
}

disposition, summary, sentiment and extracted_data are filled a few seconds after the call ends (event call.analyzed). runtime_config is null until the call is answered. Filters on list: status, agent_id, created_after, created_before.

Transcript

GET /v1/calls/{id}/transcript
{ "object": "transcript", "call_id": "...", "livemode": true,
  "messages": [ { "role": "assistant", "content": "नमस्ते! ...", "ts": "...", "node_id": "opening_greeting" },
                { "role": "user", "content": "हाँ बोलिए", "ts": "...", "node_id": "opening_greeting" } ] }

Hang up

POST /v1/calls/{id}/hangup ends a ringing or live call from your side and returns 202 {"status": "ending"}; billing and the call.completed event follow normally. Ended calls return 409 call_already_ended.

Recording

Recording is off by default and opted into per business (your flow must disclose it to the caller). When a call was recorded and the file is stored, the call object carries recording.status = "stored" and a stable, authenticated recording_url:

GET /v1/calls/{id}/recording

Returns audio/mpeg — either streamed, or a 307 to a short-lived signed URL when the file lives in object storage. Send your API key; there is no public URL. 404 recording_unavailable while it is pending (the carrier delivers the file 10–60 s after hangup) or when the business did not opt in. Requires calls:read.

Do-not-call list

A number on the business's do-not-call list is refused by POST /v1/calls, by campaigns and by the console with 403 do_not_call until it is removed. Test and live keys share the same list — a person who opted out has opted out. Numbers are matched in every Indian shape (+91XXXXXXXXXX, 91XXXXXXXXXX, XXXXXXXXXX).

GET /v1/do_not_call
POST /v1/do_not_call  { "numbers": ["+919876543210", ...], "reason": "asked to stop" } → 201 { "data": [entries], "already_present": [...], "invalid": [...] }
DELETE /v1/do_not_call/{phone}

Reads need calls:read, writes calls:write. Campaign dialing additionally honours a calling window (default 09:00–21:00 Asia/Kolkata, per business) — outside it queued calls simply wait.

Usage & billing

API calls draw from the same prepaid wallet as the console, per minute, at the API rate: your console rate plus a platform premium, less a volume discount by monthly API minutes. The rate that applied is frozen on each call (cost.rate_paise_per_min), so history never re-prices itself.

GET /v1/usage?month=2026-08
{
  "object": "usage", "livemode": true, "month": "2026-08",
  "api":          { "calls": 412, "minutes": 618.5, "billed_paise": 296880, "billed_inr": 2968.80 },
  "all_channels": { "calls": 530, "minutes": 802.0, "billed_paise": 370400, "billed_inr": 3704.00 },
  "rate_card": {
    "premium_pct": 20,
    "modes": { "agentic": { "rate_inr_per_min": 7.2, "tier_discount_pct": 0, ... },
               "scripted": { "rate_inr_per_min": 4.2, ... } },
    "tiers": [ { "min_monthly_minutes": 10000, "discount_pct": 10 },
               { "min_monthly_minutes": 50000, "discount_pct": 20 } ]
  },
  "wallet": { "balance_paise": 100000, "balance_inr": 1000.0, "currency": "INR" }
}
A call is rejected with 402 before dialling if the wallet cannot fund at least one minute. Watch for wallet.low_balance webhooks and top up from the console.

Webhooks

Register HTTPS endpoints in the console (Developers → Webhooks) or via the API and receive signed events. An endpoint created with a test key is a test endpoint and receives only sandbox events (livemode: false); one created with a live key receives only real events. The two never mix.

GET /v1/webhook_endpoints
POST /v1/webhook_endpoints
GET /v1/webhook_endpoints/{id}
PATCH /v1/webhook_endpoints/{id}
DELETE /v1/webhook_endpoints/{id}
GET /v1/webhook_endpoints/{id}/secret
POST /v1/webhook_endpoints/{id}/rotate_secret
POST /v1/webhook_endpoints/{id}/test
GET /v1/webhook_endpoints/{id}/deliveries
POST /v1/webhook_endpoints/{id}/deliveries/{delivery_id}/redeliver
curl https://api.shravan-ai.tech/v1/webhook_endpoints \
  -H "Authorization: Bearer $SHRAVAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/webhooks/shravan", "events": ["call.completed", "call.analyzed", "wallet.*"], "description": "CRM sync"}'

Rules: https:// only, public hosts only (localhost, private and link-local addresses are refused, and the address is re-checked at every delivery); at most 10 endpoints per mode; events accepts exact types, call.* / wallet.*, or *. Requires scope webhooks:manage.

EventWhen
call.initiatedThe carrier accepted the call.
call.answeredThe callee picked up; the agent is speaking.
call.completedThe call ended; duration and cost are final.
call.failedNot answered, busy, rejected, or a carrier error (failure_reason).
call.analyzedDisposition, summary, extracted data and transcript are ready.
wallet.low_balanceThe wallet dropped below the safety threshold.
wallet.creditedA top-up was captured.

Payload

POST https://your.app/shravan
Content-Type: application/json
User-Agent: Shravan-Webhooks/1.0
Shravan-Signature: t=1755422400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8c2
Shravan-Event-Id: evt_3f9c...   Shravan-Event-Type: call.completed
Shravan-Delivery-Id: whd_...    Shravan-Webhook-Version: 2026-08-01

{ "id": "evt_3f9c...", "object": "event", "type": "call.completed",
  "created": 1755422400, "livemode": true, "api_version": "2026-08-01",
  "data": { "object": { ...the call object... } } }

call.analyzed additionally carries data.object.transcript (capped at 64 KB; transcript_truncated: true when cut). Wallet events carry { "object": "wallet", "business_id", "balance_paise", ... }.

Verify the signature

Compute HMAC-SHA256 over "{t}.{raw_body}" (the exact bytes received) with your endpoint's signing secret and compare, in constant time, against each v1 value — during a secret rotation the header carries two v1 entries (new and previous) for the grace period. Reject timestamps older than 5 minutes.

import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    fields = {}
    for part in header.split(","):
        k, _, v = part.strip().partition("=")
        fields.setdefault(k, []).append(v)
    t = int(fields["t"][0])
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, candidate) for candidate in fields.get("v1", []))

# Flask / FastAPI: pass request.get_data() / await request.body() -- never a re-serialized JSON.

Delivery semantics

Knowledge

Answers to off-script questions come from your business's knowledge base. There is one knowledge base per business, shared by test and live keys (sandbox calls do not consult it). Requires knowledge:read / knowledge:write.

GET /v1/knowledge
POST /v1/knowledge
POST /v1/knowledge/upload
GET /v1/knowledge/{id}
DELETE /v1/knowledge/{id}
# A text entry
curl https://api.shravan-ai.tech/v1/knowledge \
  -H "Authorization: Bearer $SHRAVAN_API_KEY" -H "Content-Type: application/json" \
  -d '{"title": "Refund policy", "content": "Full refund within 7 days of purchase...", "tags": ["policy"], "metadata": {"source": "crm"}}'

# A PDF (chunked into documents; max 10 MB, 300 pages)
curl https://api.shravan-ai.tech/v1/knowledge/upload \
  -H "Authorization: Bearer $SHRAVAN_API_KEY" -F "file=@brochure.pdf"
{
  "id": "kb_3f2a9c1d7e5b",
  "object": "knowledge_document",
  "title": "Refund policy",
  "content": "Full refund within 7 days of purchase...",
  "tags": ["policy"],
  "metadata": { "source": "crm" },
  "source": { "type": "text", "filename": null, "chunk_index": null },   // "pdf" + filename + chunk_index for uploads
  "status": "processing",           // processing | ready | failed
  "indexing": { "keyword": true, "semantic": false, "error": null },
  "is_active": true,
  "created_at": "2026-08-17T10:20:00+00:00", "updated_at": "2026-08-17T10:20:00+00:00"
}
FieldNotes
title, contentRequired. Content up to 20,000 characters; upload a PDF for longer material — it is split into ~1,000-character documents on paragraph boundaries, titled name (Part n).
tags, metadataOptional. Up to 25 tags (≤48 chars each); metadata is the same bounded bag as on calls (≤20 scalar keys, ≤4 KB).
statusIngestion is asynchronous. A document is keyword-searchable the moment it is created; processing means the semantic (vector) index is being built, usually within seconds; ready means both indexes serve it; failed means embedding failed after retries (indexing.error) — keyword retrieval still works.
ListCursor pagination like every list; rows omit content unless include_content=true.

Changelog

2026-08-18 — Call recordings (opt-in per business): GET /v1/calls/{id}/recording, recording_url + recording on the call object. Do-not-call list: GET/POST/DELETE /v1/do_not_call; POST /v1/calls returns 403 do_not_call for listed numbers. Campaign calling window (09:00–21:00 IST default).

2026-08-17 — Webhooks are live (endpoints API, delivery log, redelivery, secret rotation, test sends). /v1/knowledge (create, upload, list, get, delete). Agent voice/language/model overrides now drive the live call; calls carry runtime_config and agent_snapshot.

2026-08-01 — First public version: scoped keys, test mode, agents, calls, transcripts, hangup, usage, idempotency, webhook signature scheme.

Questions: hello@shravan-ai.tech. Include the X-Request-Id of any failing request.