InfraVoice

Analytics, QA & Billing

Once your agents are live, five questions follow: how fast are they?, did anything break?, were the calls any good?, will a change break them?, and what did it all cost? This page covers the APIs that answer those — pipeline analytics, alert rules, QA scorecards, whole-call analysis, simulation test runs, and billing.

Base URL: https://your-infravoice-host/api/v1 Auth: Authorization: Bearer usf-… — your organization key alone is enough for every endpoint on this page. Copy it from the dashboard under Settings → Organization key. Response envelope: everything here is served by a Go service, so bodies are wrapped: {"success":true,"data":…,"error":null,"meta":…,"timestamp":"…"}. Read your result from data. InfraVoice's telephony, call-bridge and voice-pipeline services return raw JSON with no wrapper — if you build one shared client, branch on the endpoint family rather than assuming one shape. Scoping: almost every read takes a workspace_id (shown in the dashboard under Settings). The billing endpoints fall back to your organization's primary workspace if you omit it.


Endpoints

Analytics

MethodEndpointPurpose
GET/api/v1/analytics/latencyAverage latency per pipeline stage
GET/api/v1/analytics/usageSession / conversation / message volume and latency percentiles
GET/api/v1/analytics/tool-callsPer-tool call counts, success rate and latency spread
GET/api/v1/conversations/analytics/pipeline-eventsCount, average and p95 latency by event type, status, provider, model — with date filters
GET/api/v1/conversations/analytics/pipeline-summaryCounts only, grouped by event type and status

Alerts

MethodEndpointPurpose
GET/api/v1/alerts/rulesList rules in a workspace
POST/api/v1/alerts/rulesCreate a rule
PUT/api/v1/alerts/rules/{id}Update a rule (partial)
DELETE/api/v1/alerts/rules/{id}Delete a rule
POST/api/v1/alerts/testDry-run a rule payload without saving it
GET/api/v1/alerts/eventsList fired events
POST/api/v1/alerts/events/{id}/resolveMark an event resolved
GET/api/v1/alerts/active-countCount of open events, across all your workspaces

QA scoring

MethodEndpointPurpose
POST/api/v1/qa/rubricsCreate a rubric
GET/api/v1/qa/rubricsList rubrics in a workspace
PUT/api/v1/qa/rubrics/{rubricId}Update a rubric
DELETE/api/v1/qa/rubrics/{rubricId}Delete a rubric
POST/api/v1/qa/rubrics/{rubricId}/bind/{agentId}Activate the rubric for an agent
DELETE/api/v1/qa/rubrics/{rubricId}/bind/{agentId}Detach it
GET/api/v1/qa/agent/{agentId}/rubricWhich rubric is bound to this agent
GET/api/v1/qa/scoresRead scores — one call, or a cohort
POST/api/v1/qa/scores/{scoreId}/reviewAttach human reviewer notes

Whole-call analysis (note: under /conversations, not /qa)

MethodEndpointPurpose
POST/api/v1/conversations/analysis/schemasDefine an extraction schema for an agent
GET/api/v1/conversations/analysis/schemasList an agent's schemas (agent_id required)
PUT/api/v1/conversations/analysis/schemas/{schemaId}Update or activate/deactivate a schema
DELETE/api/v1/conversations/analysis/schemas/{schemaId}Delete a schema
GET/api/v1/conversations/analysis/resultsExtracted values, by conversation or by agent
GET/api/v1/conversations/analysis/insightsRolled-up aggregates per field for an agent

Test runs

MethodEndpointPurpose
GET/api/v1/agents/{id}/test-casesList an agent's test cases
POST/api/v1/agents/{id}/test-casesCreate a test case
PUT/api/v1/agents/{id}/test-cases/{tcId}Update a test case
DELETE/api/v1/agents/{id}/test-cases/{tcId}Delete a test case
POST/api/v1/agents/{id}/test-runsStart a run
GET/api/v1/agents/{id}/test-runsList past runs (paginated)
GET/api/v1/test-runs/{runId}Run status and counters
GET/api/v1/test-runs/{runId}/resultsPer-case results
GET/api/v1/test-runs/{runId}/streamLive progress (Server-Sent Events)

Billing

MethodEndpointPurpose
GET/api/v1/billing/configRates, free credit, top-up limits, whether payments are available
GET/api/v1/billing/walletBalance and total spent (creates the wallet on first call)
GET/api/v1/billing/transactionsPaginated ledger
POST/api/v1/billing/topupCreate a Stripe Checkout session
GET/api/v1/billing/topup/confirmReconcile a checkout session after payment
GET/api/v1/billing/invoicesStripe invoices

Duplicate paths exist; pick one. Alert routes also answer under /api/v1/analytics/alerts/…, and Speech-to-Text key management also answers under /api/v1/billing/asr/keys. They are the same handlers — use the canonical forms above.

Quickstart

Five requests take you from "I have a key" to a complete picture of one workspace.

1. Check your balance and rates. The very first call for a workspace creates the wallet and grants your signup free credit, so this doubles as "activate my billing account".

Shell
curl https://your-infravoice-host/api/v1/billing/wallet \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY"

2. See where your call latency is going — one row per pipeline stage, with an average and a sample count.

Shell
curl "https://your-infravoice-host/api/v1/analytics/latency?agent_id=AGENT_UUID" \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY"

3. Get told when it degrades instead of checking manually.

Shell
curl -X POST https://your-infravoice-host/api/v1/alerts/rules \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "workspace_id": "WORKSPACE_UUID",
    "name": "Slow responses",
    "metric": "avg_latency_ms",
    "scope": "agent:AGENT_UUID",
    "operator": ">",
    "threshold": 2500,
    "window_minutes": 15,
    "cooldown_minutes": 60,
    "channels": { "email": ["ops@example.com"] }
  }'

4. Start scoring call quality. Create a rubric, then bind it to an agent — from that moment its calls are scored automatically.

Shell
curl -X POST https://your-infravoice-host/api/v1/qa/rubrics \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "workspace_id": "WORKSPACE_UUID",
    "name": "Support baseline",
    "pass_threshold": 70,
    "criteria": [
      { "name": "Greeting", "description": "Agent greeted the caller and identified itself", "type": "yes_no", "weight": 20 },
      { "name": "Resolution", "description": "Caller's problem was resolved or correctly escalated", "type": "score_0_100", "weight": 50 },
      { "name": "Tone", "description": "Polite and professional throughout", "type": "scale_1_5", "weight": 30 }
    ]
  }'
 
curl -X POST https://your-infravoice-host/api/v1/qa/rubrics/RUBRIC_UUID/bind/AGENT_UUID \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY"

5. Read the scores back a few calls later.

Shell
curl "https://your-infravoice-host/api/v1/qa/scores?workspace_id=WORKSPACE_UUID&agent_id=AGENT_UUID&flagged=true" \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY"

Analytics

Read-only aggregates over the messages your agents have already logged. They are aggregates, not exports — for per-message data read conversations instead. All are automatically filtered to the workspaces your key belongs to.

Latency by stage

GET /api/v1/analytics/latency takes optional agent_id (strongly recommended — mixing agents mixes providers) and session_id. It has no date range; it samples recent activity. The response contains one object per stage that actually recorded a measurement, so expect a short array on a young workspace.

JSON
{
  "success": true,
  "data": [
    { "metric_type": "latency", "component": "asr_final",    "provider": "deepgram", "model": "nova-2",        "avg_latency_ms": 312.4,  "count": 480 },
    { "metric_type": "latency", "component": "llm_api_ttfb", "provider": "openai",   "model": "gpt-4o-mini",   "avg_latency_ms": 640.1,  "count": 476 },
    { "metric_type": "latency", "component": "llm_ttfb",     "provider": "openai",   "model": "gpt-4o-mini",   "avg_latency_ms": 812.7,  "count": 476 },
    { "metric_type": "latency", "component": "llm_stream",   "provider": "openai",   "model": "gpt-4o-mini",   "avg_latency_ms": 1104.0, "count": 470 },
    { "metric_type": "latency", "component": "tts_ttfb",     "provider": "cartesia", "model": "sonic",         "avg_latency_ms": 288.9,  "count": 468 },
    { "metric_type": "latency", "component": "first_token",  "provider": null,       "model": null,            "avg_latency_ms": 1421.3, "count": 476 },
    { "metric_type": "latency", "component": "tool_execution","provider": null,      "model": null,            "avg_latency_ms": 690.2,  "count": 88 }
  ],
  "error": null,
  "timestamp": "2026-07-26T10:04:11.882Z"
}

A voice turn is a relay race, and this table tells you which runner is slow:

componentWhat it measuresIf it's high…
asr_finalCaller finishing a phrase → final transcript.Speech recognition or end-of-speech detection; check your ASR provider and endpointing.
llm_api_ttfbRaw provider time to first token — network + model alone.The model itself. Try a smaller/faster model or another provider.
llm_ttfbTime to first token as the pipeline experienced it, including queueing and context assembly.Compare with llm_api_ttfb: a big gap means the delay is ours/your prompt, not the provider's.
llm_streamStreaming the rest of the response after the first token.Long answers. Ask the agent to be concise in the system prompt.
tts_ttfbFirst byte of synthesized audio.Your voice provider. Directly audible as dead air.
tts_streamStreaming the remaining audio. Overlaps playback.Usually benign.
first_tokenEnd-to-end: caller stops speaking → agent starts speaking. The number the caller feels.Read the other rows to find the contributor.
tool_executionTime inside your tools/webhooks during a turn.Your own backend. Tool turns pay LLM latency twice, so a slow tool is doubly expensive.
ctx_processingAssembling conversation context and knowledge-base results before the LLM call.Very large context or knowledge retrieval.
system_overheadPipeline-internal work not attributable to a provider.Rarely actionable; report it if it dominates.
pipeline_routingRouting/dispatch overhead within the pipeline.Rarely actionable.

provider and model come from the first matching record, not from every row. If an agent switched models — or you query without agent_id across several agents — the labels can name one model while the average blends others. Query one agent_id at a time when attribution matters.

This is a recent-activity sample, not a full report. The query reads a bounded window of the most recent assistant turns (up to 500). Use it for spot checks and alert thresholds, not for billing reconciliation.

Usage and tool calls

GET /api/v1/analytics/usage returns volume and latency percentiles grouped per user, with optional user_id and agent_id filters, at most 50 rows. p95_latency_ms is the honest number to watch — an average hides the calls where the caller heard silence and said "hello?" again.

JSON
{
  "success": true,
  "data": [
    {
      "user_id": "8f0b…",
      "total_sessions": 214,
      "total_conversations": 231,
      "total_messages": 1848,
      "total_duration_ms": 4820310,
      "avg_latency_ms": 1402.6,
      "p95_latency_ms": 2610.0,
      "p99_latency_ms": 3980.0
    }
  ],
  "error": null,
  "timestamp": "2026-07-26T10:05:02.114Z"
}

total_messages is an estimate. It is derived from turn counts (two messages per turn), not counted row by row. Treat it as a rough volume signal; do not reconcile it against a transcript export.

GET /api/v1/analytics/tool-calls gives per-tool reliability — how often each tool was called, how often it failed, and how slow it is at the median and the tail. Optional agent_id and session_id.

JSON
{
  "success": true,
  "data": [
    {
      "tool_name": "lookup_order",
      "call_count": 412,
      "success_count": 401,
      "error_count": 11,
      "success_rate": 97.3,
      "avg_latency_ms": 480.2,
      "min_latency_ms": 121.0,
      "max_latency_ms": 8100.0,
      "p50_latency_ms": 402.0,
      "p95_latency_ms": 1310.0
    }
  ],
  "error": null,
  "timestamp": "2026-07-26T10:05:44.902Z"
}

p95_latency_ms on a tool is the number that shows up as awkward pauses mid-call; error_count is the number that shows up as the agent apologising.

Only the workspace owner sees tool-call data. This endpoint is scoped to workspaces you own, not merely belong to. A team member querying someone else's workspace gets 200 with an empty array rather than an error — so an empty result means "no data or not your workspace".

Pipeline events

Two endpoints give the same timing story with real date filters and a status breakdown. They live under /conversations, not /analytics — worth memorising, because /api/v1/analytics/pipeline-events does not exist.

Query paramRequiredApplies toDescription
workspace_idyesbothTenant scope. 400 MISSING_WORKSPACE without it.
agent_idnobothRestrict to one agent.
from, tonobothISO-8601 timestamps, inclusive.
event_typenoeventse.g. the pipeline stage name.
provider, model_idnoeventsRestrict to one provider or model.
statusnoeventse.g. success / error.
Shell
curl "https://your-infravoice-host/api/v1/conversations/analytics/pipeline-events?workspace_id=WORKSPACE_UUID&from=2026-07-01T00:00:00Z&to=2026-07-26T00:00:00Z&status=error" \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY"
JSON
{
  "success": true,
  "data": [
    { "event_type": "llm_request", "status": "error", "provider": "openai", "model_id": "gpt-4o-mini", "total": 14, "avg_latency_ms": 9021.4, "p95_latency_ms": 11004.0 }
  ],
  "error": null,
  "timestamp": "2026-07-26T10:06:20.551Z"
}

Alerts

An alert rule is a standing question — "is average latency above 2.5 s for this agent?" — evaluated in the background. When it becomes true an event is created and your channels are notified. Events stay open until you resolve them, so the dashboard banner reflects unfinished business rather than a stream of noise.

Create a rule

POST /api/v1/alerts/rules

FieldRequiredDescription
workspace_idyesYou must be a member of it.
nameyesShown in the notification and on the event.
metricyesOne of avg_latency_ms, error_rate, call_volume, provider_error, provider_rate_limit, provider_quota.
operatoryesComparison against threshold, e.g. >, <.
scopeyesMust be workspace:<uuid> or agent:<uuid>.
thresholdconditionalMust be >= 0. Not used by the provider_* metrics.
window_minutesnoHow far back each evaluation looks.
cooldown_minutesnoMinimum gap between repeat firings — your defence against alert storms.
channelsno{ "email": [], "webhook": [], "slack": [] }.

scope is the field everyone gets wrong. It must carry an id: "workspace:3f2a…" or "agent:9c81…". A bare "workspace" is rejected, and so is an empty string. POST /api/v1/alerts/test does not apply this validation, so a payload can test cleanly and still fail to save — and PUT skips the check too, which can leave a rule in a shape that never evaluates. Always send the full form.

Channel values are validated at create time and the rule is rejected as a whole if any entry is malformed: email addresses must look like addresses, Slack URLs must begin with https://hooks.slack.com/services/, webhooks must be https:// and must resolve to a public address (private, loopback and cloud-metadata destinations are rejected), and a channel list may not repeat the same destination twice.

JSON
{
  "success": true,
  "data": {
    "id": "b71c…",
    "user_id": "8f0b…",
    "workspace_id": "3f2a…",
    "name": "Slow responses",
    "metric": "avg_latency_ms",
    "scope": "agent:9c81…",
    "operator": ">",
    "threshold": 2500,
    "window_minutes": 15,
    "cooldown_minutes": 60,
    "channels": { "email": ["ops@example.com"], "webhook": [], "slack": [] },
    "is_active": true,
    "created_at": "2026-07-26T10:07:00Z",
    "updated_at": "2026-07-26T10:07:00Z"
  },
  "error": null,
  "timestamp": "2026-07-26T10:07:00.204Z"
}

Dry-run, update, delete

POST /api/v1/alerts/test takes the same body as create, evaluates it immediately, and tells you what the current value actually is — the fastest way to pick a threshold that isn't already firing or permanently asleep. Nothing is saved. Omitting window_minutes defaults it to 5 minutes for the dry run.

Shell
curl -X POST https://your-infravoice-host/api/v1/alerts/test \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"metric":"avg_latency_ms","scope":"agent:AGENT_UUID","operator":">","threshold":2500,"window_minutes":15}'
JSON
{
  "success": true,
  "data": { "observed_value": 1873.4, "threshold": 2500, "operator": ">", "fired": false },
  "error": null,
  "timestamp": "2026-07-26T10:07:31.010Z"
}

PUT /api/v1/alerts/rules/{id} takes any subset of name, metric, scope, operator, threshold, window_minutes, cooldown_minutes, channels, is_active. Setting is_active: false silences a rule without losing its history. DELETE /api/v1/alerts/rules/{id} returns {"deleted": true} inside the envelope.

Provider alerts

The three provider metrics — provider_error, provider_rate_limit, provider_quota — are event-driven, not polled. You create the rule the same way, but nothing is periodically evaluated against a threshold; instead, when one of your model or voice providers returns an error, rate-limits you, or reports an exhausted quota, a matching event is emitted immediately. threshold is ignored, and POST /api/v1/alerts/test will not tell you anything useful about them.

Events

GET /api/v1/alerts/events takes optional status (open or resolved), workspace_id and limit (default 50).

Two shapes come back from this one endpoint and a robust client handles both. Threshold events (from the polled metrics) carry observed_value and threshold. Provider events are synthetic and rule-less: they carry title, message, severity and source, and their rule_id and numeric fields are not meaningful.

JSON
{
  "success": true,
  "data": [
    {
      "id": "e91a…",
      "rule_id": "b71c…",
      "rule_name": "Slow responses",
      "rule_metric": "avg_latency_ms",
      "triggered_at": "2026-07-26T09:41:02Z",
      "observed_value": 2814.9,
      "threshold": 2500,
      "status": "open",
      "notification_status": { "email": "sent" }
    },
    {
      "id": "c40d…",
      "rule_id": null,
      "triggered_at": "2026-07-26T09:58:14Z",
      "status": "open",
      "title": "Provider rate limit",
      "message": "LLM provider returned 429 for 6 consecutive requests",
      "severity": "warning",
      "source": "provider"
    }
  ],
  "error": null,
  "timestamp": "2026-07-26T10:08:02.774Z"
}

Resolve one with POST /api/v1/alerts/events/{id}/resolve (no body), which returns {"resolved": true}. GET /api/v1/alerts/active-count returns {"count": 3} and takes no workspace_id — it deliberately counts open events across every workspace you can see, which is what a global "you have unresolved alerts" banner wants.


QA & analysis

Two different questions over the same finished calls. QA scoring asks "was this call good?" — you define a rubric and bind it to an agent. Analysis asks "what happened in this call?" — you define the structured fields to extract (did they book? what objection? what budget?).

Both are asynchronous. They run after a call completes, so scores and results appear on a delay and their status field is meaningful. Poll; don't assume a call ends with its scorecard already written.

Rubrics

POST /api/v1/qa/rubrics

FieldRequiredDescription
workspace_idyesRubrics are workspace-owned.
nameyese.g. "Support baseline".
descriptionnoFree text for your team.
criteriano (but pointless empty)Array of criteria — see below.
pass_thresholdnoOverall score at or above which a call passes. Defaults to 60.0.

Each criterion has a name, a description (the instruction the scoring model actually follows — write it as you would brief a new reviewer), a type of yes_no, scale_1_5 or score_0_100, and a weight out of 100. Weights should sum to 100, because the overall score is their weighted combination.

The strongest lever on score quality is the description. "Was the agent polite?" produces mush; "Did the agent acknowledge the caller's frustration before offering a solution?" produces a number you can act on.

A pass_threshold of exactly 0 is treated as unset and becomes 60.0. Pick a small positive number if you want an effectively-always-pass rubric.

Binding to an agent

POST /api/v1/qa/rubrics/{rubricId}/bind/{agentId}{"status": "bound"}

This is the switch that turns scoring on. Until an agent has a bound rubric, no scores are produced for it. One rubric can be bound to many agents; GET /api/v1/qa/agent/{agentId}/rubric returns the currently bound one.

An unbound agent returns 200 with data: null, not 404. Backend failures also surface as 200 null here, so this endpoint cannot distinguish "no rubric bound" from "lookup failed". Treat null as "not configured" and don't build retry logic on it.

Binding takes effect immediately — it is not part of your agent's draft. But saving an agent's prompt, providers, tools or telephony settings only updates a draft. Nothing reaches live calls until you POST /api/v1/agents/{id}/versions/publish. Scoring an agent whose improvements are still in a draft tells you about the old behaviour.

Reading scores

GET /api/v1/qa/scores has two modes that return different envelopes.

Single-call mode — pass conversation_id. data is a plain array of scorecards for that conversation, with no meta.

Cohort mode — pass workspace_id (required; 400 MISSING_PARAM without it) plus any of agent_id, from/to, min_score, flagged=true (only calls the scorer flagged), limit (default 50) and offset. Cohort mode adds a meta block with page, per_page, total, total_pages.

JSON
{
  "success": true,
  "data": [
    {
      "id": "7a19…",
      "conversation_id": "5cd2…",
      "agent_id": "9c81…",
      "rubric_id": "31ff…",
      "rubric_name": "Support baseline",
      "overall_score": 74.5,
      "criterion_scores": [
        { "name": "Greeting",   "score": 100, "reasoning": "Agent introduced itself by name.",                 "evidence_quote": "Hi, this is Emma from Acme Support." },
        { "name": "Resolution", "score": 60,  "reasoning": "Offered a workaround but never confirmed the fix.", "evidence_quote": "You could try restarting it for now." },
        { "name": "Tone",       "score": 80,  "reasoning": "Polite, slightly rushed at the close.",             "evidence_quote": "Alright, anything else? Great, bye." }
      ],
      "flagged": false,
      "reviewed_by": null,
      "reviewed_at": null,
      "review_notes": null,
      "llm_provider": "openai",
      "llm_model": "gpt-4o-mini",
      "processing_ms": 3120,
      "status": "completed",
      "created_at": "2026-07-26T09:12:44Z"
    }
  ],
  "meta": { "page": 1, "per_page": 50, "total": 1, "total_pages": 1 },
  "error": null,
  "timestamp": "2026-07-26T10:09:12.441Z"
}

criterion_scores[].evidence_quote is what makes QA defensible in a coaching conversation: every number points at the moment in the transcript that produced it. A row with a non-completed status is still in flight.

POST /api/v1/qa/scores/{scoreId}/review with {"reviewer_id": "…", "notes": "…"} attaches a human verdict on top of the machine score, returning {"status": "reviewed"}.

reviewer_id is whatever you put in the body. It is not validated against the authenticated caller, so attribution is self-asserted. If you expose review in your own UI, populate it from your own session rather than trusting a client-supplied value.

Analysis schemas

FieldRequiredDescription
agent_idyesSchemas belong to an agent.
nameyese.g. "Lead qualification".
descriptionnoContext for the extraction model.
fieldsyes in practiceArray of { key, label, type, description, options, required }.
is_activenoDefaults to true.

type is one of boolean, text, number or enum; options supplies the allowed values for an enum. The description is the extraction instruction — be specific about where in a conversation the answer should come from.

Shell
curl -X POST https://your-infravoice-host/api/v1/conversations/analysis/schemas \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "AGENT_UUID",
    "name": "Lead qualification",
    "fields": [
      { "key": "booked",    "label": "Booked a demo",  "type": "boolean", "description": "True only if a specific date and time were agreed." },
      { "key": "objection", "label": "Main objection", "type": "enum",    "options": ["price", "timing", "authority", "none"], "description": "The single biggest reason the caller hesitated." },
      { "key": "budget",    "label": "Stated budget",  "type": "number",  "description": "Monthly budget in USD if the caller stated one." }
    ]
  }'

The create response may not be the schema you just created. On success the service re-lists the agent's schemas and returns the last one. If you need the new schema's id reliably, follow up with GET /api/v1/conversations/analysis/schemas?agent_id=… and match on name.

Empty-string name or description on PUT mean "leave unchanged" — those fields cannot be cleared, only replaced. is_active does honour false, which is how you retire a schema without deleting its history.

Analysis results

GET /api/v1/conversations/analysis/results requires either conversation_id (one call) or agent_id (many calls, with limit — default 50 — and offset). Passing neither is a 400 MISSING_PARAM.

JSON
{
  "success": true,
  "data": [
    {
      "id": "aa31…",
      "conversation_id": "5cd2…",
      "schema_id": "77b0…",
      "agent_id": "9c81…",
      "status": "completed",
      "result": { "booked": true, "objection": "timing", "budget": 400 },
      "llm_provider": "openai",
      "llm_model": "gpt-4o-mini",
      "processing_ms": 2410,
      "created_at": "2026-07-26T09:13:02Z",
      "updated_at": "2026-07-26T09:13:05Z"
    }
  ],
  "error": null,
  "timestamp": "2026-07-26T10:10:44.128Z"
}

status moves through pendingprocessingcompleted (or failed, with error_message), so poll rather than assume. GET /api/v1/conversations/analysis/insights?agent_id=… gives the same data already aggregated per field — sample counts and per-field aggregates across many calls — which is what you want for a dashboard tile like "62% of callers cite timing".


Test runs

Before you publish a prompt change, have simulated callers try to break it. A test case is a persona with a goal and a set of assertions; a test run executes some or all of an agent's cases and reports pass/fail per assertion with the full transcript. Note the asymmetric paths: you create and list runs under the agent, but read them at the top level.

Test runs exercise the agent as configured, and config edits are drafts. Saving a prompt, provider, tool or telephony change updates a draft only. Publish with POST /api/v1/agents/{id}/versions/publish first, then run your tests, or you will be testing yesterday's agent.

Create a test case

FieldRequiredDescription
nameyesThe only field actually enforced.
personarecommendedWho the simulated caller is and how they behave.
goalrecommendedWhat they are trying to achieve.
assertionsrecommendedWhat must (or must not) be true — see types below.
max_turnsnoConversation cap. Defaults to 20.
tagsnoFor grouping.

Assertions are { "type": …, "value": …, "description": … } where type is one of:

TypeMeaning
must_sayThe agent has to say this (semantically, not verbatim).
must_not_sayThe agent must never say this — the one to use for compliance guardrails.
toneThe agent's manner has to match the described tone.
outcomeThe conversation has to end in this state.
customFree-form condition judged from the transcript.
Shell
curl -X POST https://your-infravoice-host/api/v1/agents/AGENT_UUID/test-cases \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Angry caller demands a refund",
    "persona": "A customer who was charged twice and is frustrated. Interrupts, repeats the complaint, does not accept the first answer.",
    "goal": "Get a refund confirmed, or a clear escalation with a timeframe.",
    "max_turns": 14,
    "tags": ["billing", "regression"],
    "assertions": [
      { "type": "must_say",     "value": "acknowledges the duplicate charge",  "description": "Agent must recognise the double charge before proposing anything." },
      { "type": "must_not_say", "value": "promises an unconditional refund",   "description": "Refunds require verification first." },
      { "type": "tone",         "value": "calm and empathetic",                "description": "No matching the caller's frustration." },
      { "type": "outcome",      "value": "escalated to a human with a timeframe" }
    ]
  }'

persona and goal are accepted empty even though the validation message mentions them. A case with no persona will still run — and will tell you nothing. Fill them in.

Updates are a partial merge, and empty strings (plus max_turns: 0) mean "unchanged" — you can replace those fields but you cannot blank them.

Start a run

POST /api/v1/agents/{id}/test-runs takes an optional case_ids array (omit it to run every case the agent has) and an optional agent_version the run is recorded against. It returns 201 with the run, whose status starts at queued and whose total tells you how many cases were accepted.

JSON
{
  "success": true,
  "data": {
    "id": "run_4f2c…",
    "agent_id": "9c81…",
    "agent_version": 7,
    "status": "queued",
    "total": 4,
    "passed": 0,
    "failed": 0,
    "errored": 0,
    "tokens_used": 0,
    "triggered_by": "8f0b…",
    "started_at": "2026-07-26T10:11:00Z"
  },
  "error": null,
  "timestamp": "2026-07-26T10:11:00.318Z"
}

Two behaviours to code defensively against: unknown ids in case_ids are silently skipped (check total against what you sent), and an agent with no test cases at all returns 400 NO_CASES. If a run stays in its initial status and never progresses, treat that as a failure rather than waiting indefinitely — a run is created before the work is dispatched, so a dispatch problem looks like a permanently queued run.

Poll or stream

GET /api/v1/test-runs/{runId} returns the run; status ends at completed, failed or cancelled. GET /api/v1/test-runs/{runId}/results returns one entry per case. Result placeholders are created up front, so entries appear with status: "pending" before the simulator fills them in.

JSON
{
  "success": true,
  "data": [
    {
      "id": "res_a1…",
      "test_run_id": "run_4f2c…",
      "test_case_id": "tc_77…",
      "test_case_name": "Angry caller demands a refund",
      "status": "fail",
      "turns_taken": 11,
      "duration_ms": 41200,
      "tokens_used": 5120,
      "retry_count": 0,
      "transcript": [
        { "role": "assistant", "content": "Thanks for calling Acme, this is Emma." },
        { "role": "user", "content": "You charged me twice and I want my money back." }
      ],
      "assertion_results": [
        { "assertion": { "type": "must_say", "value": "acknowledges the duplicate charge" }, "passed": true,  "reasoning": "Agent restated the duplicate charge in turn 2." },
        { "assertion": { "type": "outcome", "value": "escalated to a human with a timeframe" }, "passed": false, "reasoning": "Escalation offered but no timeframe given." }
      ],
      "created_at": "2026-07-26T10:11:05Z"
    }
  ],
  "error": null,
  "timestamp": "2026-07-26T10:12:40.771Z"
}

For a live view, GET /api/v1/test-runs/{runId}/stream is Server-Sent Events — not JSON, not a WebSocket. Send Accept: text/event-stream and read data: frames containing {"run": …, "results": […]}. A frame arrives roughly every 2 seconds and the server closes the stream when the run finishes.

Shell
curl -N "https://your-infravoice-host/api/v1/test-runs/RUN_ID/stream" \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Accept: text/event-stream"

Because the stream is a 2-second poll under the hood, polling the run yourself on the same interval gives you the same information with simpler client code and no long-lived connection to babysit. Use SSE for a dashboard, polling for a CI script.


Billing & wallet

One wallet per organization, denominated in cents, drawn down by usage and topped up through Stripe Checkout. Voice calls are metered per minute; speech-to-text is metered per audio-second and lands in the same ledger — see Speech-to-Text for everything except the money side. All billing endpoints accept an optional workspace_id; omit it and your organization's primary workspace is used.

Config

GET /api/v1/billing/config takes no parameters and tells you everything your UI needs to render honestly. Read it before you build a top-up flow.

JSON
{
  "success": true,
  "data": {
    "rate_per_min_usd": 0.09,
    "signup_free_credits_usd": 5,
    "currency": "usd",
    "enforce": true,
    "min_topup_usd": 5,
    "max_topup_usd": 500,
    "stripe_configured": true,
    "stripe_mode": "live"
  },
  "error": null,
  "timestamp": "2026-07-26T10:13:02.900Z"
}

enforce tells you whether an empty balance actually blocks usage on this deployment or merely records a negative. stripe_configured is false whenever payments cannot be taken — including when the deployment's keys are misconfigured, not only when they are absent — so gate your "Add credit" button on it rather than on the top-up call failing. Use min_topup_usd/max_topup_usd to validate the amount client-side.

Wallet and ledger

Shell
curl "https://your-infravoice-host/api/v1/billing/wallet?workspace_id=WORKSPACE_UUID" \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY"
JSON
{
  "success": true,
  "data": {
    "wallet": {
      "id": "w_31a…",
      "workspace_id": "3f2a…",
      "org_id": "org_88…",
      "balance_cents": 4873,
      "currency": "usd",
      "created_at": "2026-06-02T11:20:00Z",
      "updated_at": "2026-07-26T09:58:41Z"
    },
    "rate_per_min_usd": 0.09,
    "currency": "usd",
    "enforce": true,
    "total_spent_cents": 12730
  },
  "error": null,
  "timestamp": "2026-07-26T10:13:40.210Z"
}

This endpoint has a side effect. The first ever call for a workspace creates the wallet and grants the signup free credit. That is deliberate — it is how a new account gets its free balance — but it means "read the wallet" is not a pure read the very first time. Everything after that is idempotent.

If the spend rollup query fails, total_spent_cents degrades to 0 rather than failing the whole request, so a sudden 0 next to a plausible balance means "unavailable", not "you've spent nothing".

GET /api/v1/billing/transactions returns the ledger, newest first, with limit (default 50; values <= 0 or > 200 are coerced back to 50) and offset.

JSON
{
  "success": true,
  "data": [
    {
      "id": "tx_9a…",
      "wallet_id": "w_31a…",
      "workspace_id": "3f2a…",
      "type": "call_charge",
      "amount_cents": -18,
      "balance_after_cents": 4873,
      "description": "Voice call — 2.0 min",
      "session_id": "sess_44…",
      "agent_id": "9c81…",
      "connection_type": "telephony",
      "duration_seconds": 121.4,
      "rate_per_min_usd": 0.09,
      "created_at": "2026-07-26T09:58:41Z"
    },
    {
      "id": "tx_71…",
      "wallet_id": "w_31a…",
      "workspace_id": "3f2a…",
      "type": "topup",
      "amount_cents": 5000,
      "balance_after_cents": 5891,
      "description": "Stripe top-up",
      "stripe_checkout_session_id": "cs_test_…",
      "created_at": "2026-07-20T14:02:10Z"
    }
  ],
  "meta": { "page": 1, "per_page": 50, "total": 214, "total_pages": 5 },
  "error": null,
  "timestamp": "2026-07-26T10:14:12.055Z"
}

Entry type values you will see: signup_credit, topup, call_charge, adjustment, gift_credit, and speech-to-text charges. Charges are negative amount_cents; credits are positive. balance_after_cents lets you reconcile a running balance without re-summing the whole ledger.

The ledger is organization-wide, not workspace-filtered. One wallet is shared by every workspace in your organization, so workspace_id here only selects which wallet to resolve — the rows returned cover all activity against that wallet. Group by the per-row workspace_id if you need a per-workspace breakdown.

Adding credit

POST /api/v1/billing/topup with {"amount_usd": 50} (and optionally workspace_id) returns a Stripe Checkout session. Redirect the user to url; keep session_id. amount_usd must fall between min_topup_usd and max_topup_usd from /billing/config. Your first top-up also creates the Stripe customer record behind the wallet, which is what later makes invoices available.

Shell
curl -X POST https://your-infravoice-host/api/v1/billing/topup \
  -H "Authorization: Bearer usf-YOUR_ORG_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount_usd": 50}'
JSON
{
  "success": true,
  "data": { "url": "https://checkout.stripe.com/c/pay/cs_live_…", "session_id": "cs_live_…" },
  "error": null,
  "timestamp": "2026-07-26T10:15:00.442Z"
}

Credit normally lands via Stripe's webhook the moment payment completes. On your success-redirect page you can also call GET /api/v1/billing/topup/confirm?session_id=…&workspace_id=…, which returns {"credited": true}, or {"credited": false, "payment_status": "…"} with a 200 when the session is not yet paid — an unpaid session is a state, not an error. Confirm is idempotent and safe to retry: double-crediting is prevented at the database level, so the webhook and the redirect can both fire without doubling the balance. This path exists so top-ups still work on deployments Stripe's webhook cannot reach.

GET /api/v1/billing/invoices?workspace_id=… lists up to 50 Stripe invoices with hosted_invoice_url and invoice_pdf links for your finance team. It returns an empty array — not an error — when payments are unconfigured or the wallet has no Stripe customer yet, which is the normal state before the first top-up.

InfraVoice also exposes a Stripe webhook at https://your-infravoice-host/api/v1/billing/webhook/stripe. You never call it; it is the URL configured in the Stripe dashboard for the deployment, and it is what credits wallets automatically.


Errors

Errors use the same envelope with success: false and an error object:

JSON
{
  "success": false,
  "error": { "code": "MISSING_WORKSPACE", "message": "workspace_id query parameter is required" },
  "timestamp": "2026-07-26T10:16:00.001Z"
}
HTTPCodeWhereMeaning
400MISSING_WORKSPACEalerts, pipeline eventsworkspace_id is required and was not supplied.
400MISSING_PARAMQA, analysis, billingA required query/body parameter is missing (workspace_id, agent_id, or conversation_id/agent_id on analysis results).
400MISSING_FIELDSalert rulesname, metric or operator was empty.
400INVALID_BODY / INVALID_REQUESTmost writesThe JSON body could not be parsed.
400NO_CASEStest runsThe agent has no test cases to run.
401everywhereMissing, malformed or revoked usf- key.
403everywhereYour key's organization is not a member of the workspace, agent or conversation you referenced.
404NOT_FOUNDby-id readsThe id does not exist, or does not belong to you.
402insufficient_creditsmetered usageThe wallet is empty and enforce is on. Top up.
500QUERY_FAILED / LIST_FAILED / COHORT_FAILEDreadsA backend query failed — retry.
502STRIPE_ERRORtop-upStripe returned an error.
503STRIPE_NOT_CONFIGUREDtop-upPayments are not available on this deployment. Check stripe_configured first.

By-id routes are fenced to your organization before the handler runs, so asking for someone else's rule, rubric, score or schema is refused rather than answered. Do not read a 404 as proof that an id never existed.

Empty arrays are the normal empty state. Most reads on this page return data: [] rather than a 404 when there is nothing yet — including some cases where a permission scope, rather than a lack of data, is the reason. Check your workspace_id before concluding your agents are idle.