Use the Vaspian API
Read call analysis, transcripts, recording links, analytics and your contact directory over HTTPS and JSON.
Overview
The Vaspian Conversational Intelligence API gives your own systems the same call data you see in the Vaspian app: each call with its AI analysis (summary, topics, temperament, outcome, action items), full transcripts, recording download links, aggregate analytics, your contact directory and staff seats. Every credential belongs to one workspace and only ever sees that workspace's data.
This guide explains the concepts. For every endpoint, field and response, use the reference: Swagger UI (interactive, with Try it out) or ReDoc (a readable, searchable reference). Using an AI assistant instead? See the MCP server docs.
Request anatomy
Every request has this shape:
{METHOD} https://ci.vaspian.ai/api/public/v1/{resource}?{query}
| Part | Meaning |
|---|---|
{METHOD} | The HTTP method: GET, POST or PATCH. |
https://ci.vaspian.ai | The host serving your workspace. Always HTTPS. |
/api/public/v1 | The API root, which includes the version. |
{resource} | For example calls, calls/{call_id}/transcript or analytics/summary. |
{query} | Optional filters, such as from, to, limit. |
HTTP methods
| Method | Used for |
|---|---|
GET | Read calls, transcripts, recording links, analytics, contacts and staff. Never changes data. |
POST | Only /auth/token: exchange client credentials for an access token. |
PATCH | Only /calls/{call_id}/attention: resolve or re-open a flagged call. |
There is no PUT or DELETE: the API cannot create or delete calls.
Versioning
The version is part of the path: everything lives under /api/public/v1. v1 is the current and only version. Build against v1 and read the reference for the exact response shapes.
Authentication
The API uses the OAuth 2.0 client-credentials flow. Your Vaspian administrator provisions a client ID and client secret for your integration and chooses which scopes it holds.
- Send the client ID and secret to
POST /api/public/v1/auth/tokenas JSON. - Receive an
access_token, atoken_type(Bearer) andexpires_in(seconds). - Send the token on every other request as
Authorization: Bearer <access_token>.
POST /api/public/v1/auth/token
Content-Type: application/json
{"client_id": "your-client-id", "client_secret": "your-client-secret"}
Response (values illustrative):
{"access_token": "eyJhbGciOi...", "token_type": "Bearer", "expires_in": 300}
The lifetime comes from the identity provider and is reported in expires_in; do not hard-code it. When a request returns 401, fetch a new token and retry. Keep the secret on your server: send it only in the JSON body, never in a URL, and never from browser code.
A token carries the scopes your credential holds. Calling an endpoint without its scope returns 403; see Endpoints and scopes.
Endpoints and scopes
| Endpoint | Scope | Limit | Purpose |
|---|---|---|---|
POST /auth/token | none | 10/minute | Exchange credentials for a token. |
GET /calls | calls:read | 120/minute | List calls in a time window, newest first. |
GET /calls/{call_id} | calls:read | 120/minute | One call with its analysis. |
GET /calls/{call_id}/analysis | calls:read | 120/minute | The full stored analysis, including industry-specific fields. |
GET /calls/{call_id}/transcript | transcripts:read | 30/minute | Transcript as speaker turns. |
GET /calls/{call_id}/recording | recordings:read | 30/minute | Presigned recording download URL. |
PATCH /calls/{call_id}/attention | calls:write | 60/minute | Resolve or re-open a flagged call. |
GET /analytics/summary | analytics:read | 30/minute | Aggregate metrics over a window. |
GET /contacts | contacts:read | 60/minute | The contact directory. |
GET /staff | staff:read | 120/minute | Staff seats. |
GET /staff/{extension}/action-items | calls:read | 120/minute | A seat's action items, newest call first. |
Transcripts, recordings and the contact directory have their own scopes because they can contain sensitive personal information; a credential that can read call metadata cannot read them unless it also holds those scopes. A call that does not exist, or belongs to another workspace, returns 404.
PATCH /calls/{call_id}/attention takes a JSON body: status (resolved or open) and an optional note.
Query parameters
Time windows: from and to
Windows are half-open: from is inclusive, to is exclusive. Both are ISO-8601 datetimes. A value with no timezone is read as UTC. Use the Z suffix (2026-10-01T00:00:00Z); if you write an offset such as +02:00, URL-encode the + as %2B.
| Endpoint | from / to | Longest window |
|---|---|---|
GET /calls | both required | 31 days |
GET /analytics/summary | both required | 92 days |
GET /staff/{extension}/action-items | optional; to defaults to now, from to 30 days before to | 31 days |
A window with to not after from, or longer than the maximum, returns 422. To cover a longer period, make several requests.
Other parameters
| Endpoint | Parameter | Meaning |
|---|---|---|
GET /calls | limit | Page size, 1 to 500 (default 100). |
cursor | The next_cursor of the previous page. | |
direction | inbound or outbound. | |
needs_attention | true or false. | |
extension, number | Filter by extension (up to 10 characters) or phone number (up to 20). | |
min_duration_sec | Only calls at least this many seconds long. | |
min_compliance_score, max_compliance_score | Debt collection: only right-party contacts whose Compliance Score (0 to 100) is in this range. Each call's score and checks are in its compliance object. | |
GET /analytics/summary | group_by | day, week or extension. Omit for totals only. |
GET /contacts | category | Repeatable: staff, customer, misc, driver, fleet_manager. Unknown values return 422. |
number | Partial match on the number. | |
include_excluded | Include contacts excluded from analysis (default false; the flag is always returned). | |
limit | 1 to 1000 (default 200). | |
cursor | The next_cursor of the previous page. | |
GET /staff/{extension}/action-items | limit, cursor | 1 to 500 (default 100), as for /calls. |
Headers
| Header | Direction | Notes |
|---|---|---|
Authorization: Bearer <token> | Request | Required on every endpoint except /auth/token. |
Content-Type: application/json | Request | For the POST and PATCH bodies. |
Content-Type | Response | application/json on success, application/problem+json on errors. |
WWW-Authenticate: Bearer | Response | Sent with 401. |
Paging
/calls, /contacts and /staff/{extension}/action-items use keyset paging. Each page has a data array and a pagination object:
{"data": [ ... ], "pagination": {"next_cursor": "eyJ0cyI6...", "has_more": true}}
Pass next_cursor back unchanged as cursor, with the same other parameters, to get the next page. Stop when next_cursor is null. has_more is true when a page came back full, so the final page can be empty: always loop on next_cursor. Treat the cursor as opaque. An invalid cursor returns 400. /staff is not paged.
Errors
Every error is an RFC 7807 problem document, served as application/problem+json:
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Invalid or expired credentials",
"instance": "/api/public/v1/calls"
}
| Status | When |
|---|---|
400 | Malformed request, for example an invalid cursor. |
401 | Missing, expired or invalid bearer token. |
403 | The credential lacks the scope the endpoint needs. |
404 | Not found, or not visible to your workspace. |
422 | The request failed validation, including window rules. |
429 | Rate limited. See Rate limits. |
503 | Only /auth/token: the identity provider is unreachable. Retry shortly. |
Validation errors (422) add an errors array naming each failed field:
{
"type": "about:blank",
"title": "Unprocessable Entity",
"status": 422,
"detail": "query.from: Field required",
"instance": "/api/public/v1/calls",
"errors": [{"loc": ["query", "from"], "msg": "Field required", "type": "missing"}]
}
Window violations carry a message such as Window too large: /calls accepts at most 31 days. Page through with several requests. A rejected credential exchange on /auth/token also carries an OAuth error code (for example invalid_client). Do not parse detail; branch on status.
Rate limits
Each endpoint has its own per-minute limit, listed in Endpoints and scopes. Limits are counted separately per endpoint, for each credential (and by caller address for /auth/token). The server keeps the counters in memory per instance, so treat the numbers as approximate.
An over-limit request returns 429 as a problem document. The response does not include Retry-After or X-RateLimit-* headers, so back off yourself: wait a few seconds, then retry with a growing delay, and spread requests out rather than bursting. Fetch a token once and reuse it until it nears expires_in instead of requesting one per call; the token endpoint has the lowest limit.
Tools
- Swagger UI at /api/public/v1/docs: call the API from your browser. Get a token from
/auth/tokenwith Try it out, press Authorize, paste theaccess_token, then try any endpoint. - ReDoc at /api/public/v1/redoc: the same reference as a three-panel page with request samples.
- OpenAPI document at /api/public/v1/openapi.json: import it into Postman (Import, then Link, using this URL) or any OpenAPI code generator.
- MCP server: connect an AI assistant to the same data. See /mcp/docs.
Worked example
Fetch a token, list the last 24 hours of calls across all pages, then read the transcript of the first call that has one. The credential needs calls:read and transcripts:read. Set CI_CLIENT_ID and CI_CLIENT_SECRET in your environment.
BASE=https://ci.vaspian.ai/api/public/v1
TOKEN=$(curl -s "$BASE/auth/token" \
-H "Content-Type: application/json" \
-d "{\"client_id\":\"$CI_CLIENT_ID\",\"client_secret\":\"$CI_CLIENT_SECRET\"}" \
| jq -r .access_token)
TO=$(date -u +%Y-%m-%dT%H:%M:%SZ)
FROM=$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ) # macOS: date -u -v-24H ...
curl -s --get "$BASE/calls" \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode "from=$FROM" --data-urlencode "to=$TO" --data-urlencode "limit=100"
# repeat with --data-urlencode "cursor=<next_cursor>" until next_cursor is null
curl -s "$BASE/calls/CALL_ID/transcript" -H "Authorization: Bearer $TOKEN"import os
from datetime import datetime, timedelta, timezone
import requests
BASE = "https://ci.vaspian.ai/api/public/v1"
resp = requests.post(
f"{BASE}/auth/token",
json={"client_id": os.environ["CI_CLIENT_ID"], "client_secret": os.environ["CI_CLIENT_SECRET"]},
timeout=15,
)
resp.raise_for_status()
headers = {"Authorization": f"Bearer {resp.json()['access_token']}"}
fmt = "%Y-%m-%dT%H:%M:%SZ"
end = datetime.now(timezone.utc)
params = {"from": (end - timedelta(hours=24)).strftime(fmt), "to": end.strftime(fmt), "limit": 100}
calls = []
while True:
page = requests.get(f"{BASE}/calls", headers=headers, params=params, timeout=30)
page.raise_for_status()
body = page.json()
calls += body["data"]
cursor = body["pagination"]["next_cursor"]
if not cursor:
break
params["cursor"] = cursor
first = next((c for c in calls if c["has_transcript"]), None)
if first:
t = requests.get(f"{BASE}/calls/{first['call_id']}/transcript", headers=headers, timeout=30)
t.raise_for_status()
for turn in t.json()["turns"]:
print(f"[{turn['start_sec']:>7.1f}s] {turn['speaker']}: {turn['text']}")// Node 18+ (built-in fetch)
const BASE = "https://ci.vaspian.ai/api/public/v1";
async function api(path, options = {}) {
const res = await fetch(`${BASE}${path}`, options);
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.title}: ${body.detail}`);
return body;
}
const { access_token } = await api("/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: process.env.CI_CLIENT_ID,
client_secret: process.env.CI_CLIENT_SECRET,
}),
});
const auth = { headers: { Authorization: `Bearer ${access_token}` } };
const iso = (d) => d.toISOString().replace(/\.\d{3}Z$/, "Z");
const end = new Date();
const params = new URLSearchParams({
from: iso(new Date(end - 24 * 3600 * 1000)),
to: iso(end),
limit: "100",
});
const calls = [];
for (;;) {
const page = await api(`/calls?${params}`, auth);
calls.push(...page.data);
if (!page.pagination.next_cursor) break;
params.set("cursor", page.pagination.next_cursor);
}
const first = calls.find((c) => c.has_transcript);
if (first) {
const t = await api(`/calls/${first.call_id}/transcript`, auth);
for (const turn of t.turns) console.log(`[${turn.start_sec}s] ${turn.speaker}: ${turn.text}`);
}A call in the list looks like this (values illustrative; analysis fields are null until the call has been analyzed):
{
"call_id": "c0ffee-1234",
"direction": "inbound",
"from": "+15551230000",
"to": "+15559870000",
"extension": "204",
"started_at": "2026-10-05T14:02:11Z",
"ended_at": "2026-10-05T14:07:40Z",
"duration_sec": 329,
"has_transcript": true,
"has_recording": true,
"analyzed": true,
"summary": "Caller asked about a billing discrepancy...",
"key_topics": ["billing"],
"temperament": {"label": "neutral", "score": 0.1},
"needs_attention": false,
"action_items": ["Send corrected invoice"]
}
Recording links from /calls/{call_id}/recording are presigned URLs that expire after 300 seconds (the response carries expires_in); download the audio promptly.
