Vaspian

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}
PartMeaning
{METHOD}The HTTP method: GET, POST or PATCH.
https://ci.vaspian.aiThe host serving your workspace. Always HTTPS.
/api/public/v1The 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

MethodUsed for
GETRead calls, transcripts, recording links, analytics, contacts and staff. Never changes data.
POSTOnly /auth/token: exchange client credentials for an access token.
PATCHOnly /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.

  1. Send the client ID and secret to POST /api/public/v1/auth/token as JSON.
  2. Receive an access_token, a token_type (Bearer) and expires_in (seconds).
  3. 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

EndpointScopeLimitPurpose
POST /auth/tokennone10/minuteExchange credentials for a token.
GET /callscalls:read120/minuteList calls in a time window, newest first.
GET /calls/{call_id}calls:read120/minuteOne call with its analysis.
GET /calls/{call_id}/analysiscalls:read120/minuteThe full stored analysis, including industry-specific fields.
GET /calls/{call_id}/transcripttranscripts:read30/minuteTranscript as speaker turns.
GET /calls/{call_id}/recordingrecordings:read30/minutePresigned recording download URL.
PATCH /calls/{call_id}/attentioncalls:write60/minuteResolve or re-open a flagged call.
GET /analytics/summaryanalytics:read30/minuteAggregate metrics over a window.
GET /contactscontacts:read60/minuteThe contact directory.
GET /staffstaff:read120/minuteStaff seats.
GET /staff/{extension}/action-itemscalls:read120/minuteA 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.

Endpointfrom / toLongest window
GET /callsboth required31 days
GET /analytics/summaryboth required92 days
GET /staff/{extension}/action-itemsoptional; to defaults to now, from to 30 days before to31 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

EndpointParameterMeaning
GET /callslimitPage size, 1 to 500 (default 100).
cursorThe next_cursor of the previous page.
directioninbound or outbound.
needs_attentiontrue or false.
extension, numberFilter by extension (up to 10 characters) or phone number (up to 20).
min_duration_secOnly calls at least this many seconds long.
min_compliance_score, max_compliance_scoreDebt 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/summarygroup_byday, week or extension. Omit for totals only.
GET /contactscategoryRepeatable: staff, customer, misc, driver, fleet_manager. Unknown values return 422.
numberPartial match on the number.
include_excludedInclude contacts excluded from analysis (default false; the flag is always returned).
limit1 to 1000 (default 200).
cursorThe next_cursor of the previous page.
GET /staff/{extension}/action-itemslimit, cursor1 to 500 (default 100), as for /calls.

Headers

HeaderDirectionNotes
Authorization: Bearer <token>RequestRequired on every endpoint except /auth/token.
Content-Type: application/jsonRequestFor the POST and PATCH bodies.
Content-TypeResponseapplication/json on success, application/problem+json on errors.
WWW-Authenticate: BearerResponseSent 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"
}
StatusWhen
400Malformed request, for example an invalid cursor.
401Missing, expired or invalid bearer token.
403The credential lacks the scope the endpoint needs.
404Not found, or not visible to your workspace.
422The request failed validation, including window rules.
429Rate limited. See Rate limits.
503Only /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

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.