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.
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_..."
import requests
r = requests.get("https://api.shravan-ai.tech/v1/agents",
headers={"Authorization": "Bearer sk_test_..."})
agent_id = r.json()["data"][0]["id"]
const r = await fetch("https://api.shravan-ai.tech/v1/agents", {
headers: { Authorization: "Bearer sk_test_..." },
});
const agentId = (await r.json()).data[0].id;
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"}
}'
r = requests.post(
"https://api.shravan-ai.tech/v1/calls",
headers={"Authorization": "Bearer sk_test_...", "Idempotency-Key": "lead-8891"},
json={"agent_id": "agent_1a2b3c4d5e6f7a8b", "to": "+919876543210",
"contact_name": "Priya", "metadata": {"crm_id": "L-42"}},
)
call = r.json() # {"id": "...", "object": "call", "status": "initiated", ...}
const r = await fetch("https://api.shravan-ai.tech/v1/calls", {
method: "POST",
headers: { Authorization: "Bearer sk_test_...", "Idempotency-Key": "lead-8891",
"Content-Type": "application/json" },
body: JSON.stringify({ agent_id: "agent_1a2b3c4d5e6f7a8b", to: "+919876543210",
contact_name: "Priya", metadata: { crm_id: "L-42" } }),
});
const call = await r.json();
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
| Scope | Grants |
|---|---|
calls:write | Create calls, hang up. Implies calls:read. |
calls:read | List and read calls and transcripts. |
agents:write / agents:read | Manage / read agents. |
knowledge:write / knowledge:read | Manage / read the knowledge base. |
webhooks:manage | Create, edit, test and delete webhook endpoints. |
usage:read | Read 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:
| Key | Behaviour |
|---|---|
| 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"
}
}
| Status | type | When |
|---|---|---|
| 400 | invalid_request_error | Malformed or missing parameter; param names it. |
| 401 | authentication_error | Missing, invalid, revoked or expired key. |
| 402 | insufficient_funds_error | Wallet cannot fund the call. |
| 403 | permission_error | Missing scope, or IP not on the key's allowlist. |
| 404 | not_found_error | No such resource in this business. |
| 409 | idempotency_error | Idempotency-Key reused with a different body, or still in progress; also call_already_ended. |
| 429 | rate_limit_error | Per-key request limit or per-business call pacing; honour Retry-After. |
| 502 | telephony_error | The 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.
{
"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"
}
| Field | Notes |
|---|---|
flow_id or flow_json | Reference an existing flow, or send a full flow document to compile and save (returns 400 flow_invalid with the compiler's message). |
language | Default call language: hi, en, kn, ta, te, mr, bn, gu, ml, pa, od. Null inherits the business default. |
voice | Optional { "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, stt | Optional { "llm_model" } / { "stt_provider" } overrides. Null inherits the business default. |
telephony_provider | twilio 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
Create
| Parameter | Notes |
|---|---|
agent_id | Required. An active agent in your business. |
to | Required. Destination number; E.164 (+91...) recommended. |
contact_name | Optional. Used by the agent to address the person. |
metadata | Optional. Up to 20 scalar key/values (≤4 KB) echoed on every read and webhook — your CRM id, campaign tag, anything. |
sandbox_scenario, sandbox_duration_seconds | Test 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:
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).
{ "numbers": ["+919876543210", ...], "reason": "asked to stop" } → 201 { "data": [entries], "already_present": [...], "invalid": [...] }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.
{
"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" }
}
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.
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"}'
ep = requests.post(f"{BASE}/v1/webhook_endpoints", headers=H, json={
"url": "https://example.com/webhooks/shravan",
"events": ["call.completed", "call.analyzed", "wallet.*"], # or ["*"]
"description": "CRM sync",
}).json()
SIGNING_SECRET = ep["signing_secret"] # shown on create; GET /{id}/secret to read again
const ep = await fetch(`${BASE}/v1/webhook_endpoints`, {
method: "POST", headers: H,
body: JSON.stringify({ url: "https://example.com/webhooks/shravan", events: ["call.completed", "call.analyzed", "wallet.*"] }),
}).then((r) => r.json());
const SIGNING_SECRET = ep.signing_secret;
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.
| Event | When |
|---|---|
call.initiated | The carrier accepted the call. |
call.answered | The callee picked up; the agent is speaking. |
call.completed | The call ended; duration and cost are final. |
call.failed | Not answered, busy, rejected, or a carrier error (failure_reason). |
call.analyzed | Disposition, summary, extracted data and transcript are ready. |
wallet.low_balance | The wallet dropped below the safety threshold. |
wallet.credited | A 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.
import crypto from "node:crypto";
export function verify(rawBody, header, secret, tolerance = 300) {
const fields = {};
for (const part of header.split(",")) {
const [k, ...rest] = part.trim().split("=");
(fields[k] ||= []).push(rest.join("="));
}
const t = Number(fields.t?.[0]);
if (!t || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
return (fields.v1 || []).some((v) => v.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v)));
}
// Express: app.post("/webhooks/shravan", express.raw({ type: "application/json" }), (req, res) => { verify(req.body, req.get("Shravan-Signature"), SECRET) ... })
Delivery semantics
- Respond 2xx within 10 seconds; do the work asynchronously. Redirects are not followed.
- Failed deliveries retry with backoff — 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h — eight attempts, then the delivery is marked
dead. Dead and failed deliveries can be redelivered from the console orPOST .../deliveries/{id}/redeliver. - Each event is delivered at most once per endpoint per attempt and never duplicated across sources (a call that ends from both our media path and the carrier's status callback produces one
call.completed). Still treatidas the idempotency key on your side. - Ordering is not guaranteed across event types; use
createdand the call'sstatus. - An endpoint that fails 100 deliveries in a row is disabled and the workspace owner is emailed; re-enable it from the console after fixing the receiver.
POST /v1/webhook_endpoints/{id}/testsends a sample event immediately and returns what your receiver answered.- Rotate a secret with
POST .../rotate_secret?grace_seconds=86400: the previous secret keeps verifying for the grace period (max 7 days).
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.
# 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"
doc = requests.post(f"{BASE}/v1/knowledge", headers=H, json={
"title": "Refund policy", "content": "Full refund within 7 days of purchase...", "tags": ["policy"],
}).json()
with open("brochure.pdf", "rb") as f:
chunks = requests.post(f"{BASE}/v1/knowledge/upload", headers={"Authorization": H["Authorization"]},
files={"file": ("brochure.pdf", f, "application/pdf")}).json()
print(chunks["chunk_count"], [c["id"] for c in chunks["data"]])
const doc = await fetch(`${BASE}/v1/knowledge`, { method: "POST", headers: H,
body: JSON.stringify({ title: "Refund policy", content: "Full refund within 7 days of purchase...", tags: ["policy"] }) }).then((r) => r.json());
const form = new FormData();
form.append("file", new Blob([await fs.promises.readFile("brochure.pdf")], { type: "application/pdf" }), "brochure.pdf");
const chunks = await fetch(`${BASE}/v1/knowledge/upload`, { method: "POST", headers: { Authorization: H.Authorization }, body: form }).then((r) => r.json());
{
"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"
}
| Field | Notes |
|---|---|
title, content | Required. 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, metadata | Optional. Up to 25 tags (≤48 chars each); metadata is the same bounded bag as on calls (≤20 scalar keys, ≤4 KB). |
status | Ingestion 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. |
| List | Cursor 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.