Voxtra Docs / Integration Guide

Closedit ↔ Voxtra Integration Guide

Voxtra is the voice-platform infrastructure layer that handles SIP trunking, LiveKit room orchestration, AI agent dispatch, call recording, and booking capture. Closedit is a vertical SaaS that calls into Voxtra over HTTP. This document explains exactly what Closedit's backend needs to do and where to find the formal contract. The machine-readable contract is in docs/openapi.yaml (OpenAPI 3.1.0). See ADR-016 for the architectural rationale behind the HTTP-only boundary.


1. Getting an API Key

Voxtra API keys are in the format vx_live_<24 lowercase hex chars>. They are per-tenant and per-Voxtra-installation. There is no self-service signup — the Voxtra operator generates the key.

Steps:

  1. Log into the Voxtra admin UI at https://caller.closedit.ai/admin.
  2. Navigate to Tenants and select or create the tenant for Closedit.
  3. Open the API Keys tab for that tenant.
  4. Click Generate Key and provide a label (e.g. closedit-production).
  5. Copy the full vx_live_* key shown in the modal. It is shown exactly once and cannot be retrieved again. Store it in Closedit's secret manager (VOXTRA_API_KEY).

The key is scoped to the tenant whose ID appears in the URL path. Passing it for a different tenant's path returns 403. To rotate, generate a new key and revoke the old one without downtime — both work simultaneously until revocation.


2. The OpenAPI Spec

The formal spec is at docs/openapi.yaml. It covers every endpoint Closedit is permitted to call, plus the outbound webhook event shapes in the top-level webhooks: section. It is OpenAPI 3.1.0.

Generating types:

npx openapi-typescript docs/openapi.yaml -o src/voxtra-api.d.ts

Or use openapi-generator-cli for a full client:

npx @openapitools/openapi-generator-cli generate \
  -i docs/openapi.yaml \
  -g typescript-fetch \
  -o src/voxtra-client

Key concepts:


3. The Five Flows Closedit Cares About

3a. Place an Outbound Call

Before dispatching, you need:

Endpoint: POST /v1/tenants/:tenantId/dispatch

curl:

curl -X POST \
  "https://api.caller.closedit.ai/v1/tenants/tn_abc123/dispatch" \
  -H "Authorization: Bearer vx_live_your24hexkeyhere000000" \
  -H "Content-Type: application/json" \
  -d '{
    "trunk_id": "tt_a1b2c3d4e5f6",
    "agent_id": "ta_f6e5d4c3b2a1",
    "to_number": "+15551234567",
    "client_ref": "lead_closedit_789",
    "lead": {
      "name": "Jane Smith",
      "email": "jane@example.com"
    },
    "metadata": {
      "campaign_id": "q2_outbound_2026"
    }
  }'

Response (201):

{
  "call_id": "AJ_deadbeefcafe0001",
  "room_name": "tenant-tn_abc123-1715820000000",
  "status": "dispatched"
}

fetch equivalent:

const res = await fetch(
  `https://api.caller.closedit.ai/v1/tenants/${tenantId}/dispatch`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.VOXTRA_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      trunk_id: trunkId,
      agent_id: agentId,
      to_number: lead.phone,   // must be E.164
      client_ref: lead.id,
      lead: { name: lead.name, email: lead.email },
    }),
  }
);

if (!res.ok) {
  const err = await res.json();
  // err.error = machine code, err.message = human string
  throw new Error(`Dispatch failed: ${err.error} — ${err.message}`);
}

const { call_id, room_name } = await res.json();
// Store call_id for later status polling

Idempotency: Include Idempotency-Key: <uuid> to safely retry after network timeouts (see Section 5). Without it, check GET /tenants/:id/calls?client_ref=<ref> before retrying to confirm no call was already created.

Reference: OpenAPI path POST /tenants/{tenantId}/dispatch, tag Dispatch.


3b. Receive Call Lifecycle Webhooks

Voxtra pushes HMAC-signed HTTP POST events to your configured HTTPS endpoint for every call and booking lifecycle event. This eliminates polling.

Create an endpoint

curl -X POST \
  "https://api.caller.closedit.ai/v1/tenants/tn_abc123/webhooks" \
  -H "Authorization: Bearer vx_live_your24hexkeyhere000000" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://app.closedit.ai/webhooks/voxtra",
    "description": "Production event receiver",
    "event_types": ["*"]
  }'

Response (201):

{
  "webhook_endpoint": {
    "id": "twe_a1b2c3d4e5f6a1b2",
    "tenant_id": "tn_abc123",
    "url": "https://app.closedit.ai/webhooks/voxtra",
    "enabled": true,
    "event_types": ["*"],
    "status": "active",
    "consecutive_failures": 0
  },
  "signing_secret": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
  "_notice": "Store signing_secret securely. It will not be shown again."
}

Store signing_secret in your secret manager immediately (VOXTRA_WEBHOOK_SECRET). It is never returned again. To rotate, call POST /webhooks/:id/rotate-secret.

Event types

Type Trigger
call.started First SIP participant joins a room
call.participant_joined Each SIP participant join event
call.participant_left Each SIP participant leave event
call.completed LiveKit room closes (call ended)
booking.created AI agent captures a booking via tool call
agent.dispatch_failed createSipParticipant throws on outbound dispatch
webhook.test Synthetic event from POST /webhooks/:id/test

Pass "event_types": ["*"] to subscribe to all. Or filter: "event_types": ["call.completed", "booking.created"].

Event envelope shape

Every POST body has this structure:

{
  "id": "wdl_a1b2c3d4e5f6a1b2",
  "type": "call.completed",
  "created": 1716001800,
  "api_version": "2026-05-16",
  "data": {
    "object": {
      "tenant_id": "tn_abc123",
      "call_id": "AJ_deadbeefcafe0001",
      "client_ref": "lead_closedit_789",
      "duration_sec": 127,
      "ended_at": "2026-05-16T00:30:00.000Z"
    }
  }
}

Use id (the delivery ID, also in Voxtra-Webhook-Id header) for replay deduplication.

Verify the signature

Voxtra sets Voxtra-Signature: t=<unix_ts>,v1=<hex_hmac> on every request. The HMAC is HMAC-SHA256(secret, "${ts}.${rawBody}").

Node.js verification (Express):

import { createHmac } from 'node:crypto';

const WEBHOOK_SECRET = process.env.VOXTRA_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300; // 5 minutes

function verifyVoxtraSignature(req, secret) {
  // IMPORTANT: parse the raw body as a string — do not JSON.parse before this.
  const rawBody = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
  const sigHeader = req.headers['voxtra-signature'] || '';

  const parts = Object.fromEntries(
    sigHeader.split(',').map(p => {
      const eqIdx = p.indexOf('=');
      return [p.slice(0, eqIdx), p.slice(eqIdx + 1)];
    })
  );

  const ts = parseInt(parts.t, 10);
  const v1 = parts.v1;

  if (!ts || !v1) throw new Error('Missing signature parts');

  // Timestamp tolerance: reject stale events (replay protection).
  const age = Math.abs(Math.floor(Date.now() / 1000) - ts);
  if (age > TOLERANCE_SECONDS) throw new Error(`Timestamp too old: ${age}s`);

  const expected = createHmac('sha256', secret)
    .update(`${ts}.${rawBody}`)
    .digest('hex');

  if (v1 !== expected) throw new Error('Signature mismatch');
}

// Express route — must receive raw bytes
app.post('/webhooks/voxtra',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    try {
      verifyVoxtraSignature(req, WEBHOOK_SECRET);
    } catch (e) {
      return res.status(400).json({ error: 'invalid_signature', message: e.message });
    }

    const event = JSON.parse(req.body.toString());

    // Replay protection: store event.id in Redis/DB, reject if seen.
    // if (await redis.get(`webhook:${event.id}`)) return res.sendStatus(200);
    // await redis.setex(`webhook:${event.id}`, 86400, '1');

    switch (event.type) {
      case 'call.completed':
        // event.data.object: { call_id, client_ref, duration_sec, ... }
        break;
      case 'booking.created':
        // event.data.object: { id, full_name, preferred_datetime, client_ref, ... }
        break;
      case 'webhook.test':
        // Synthetic test event — no action needed.
        break;
    }

    res.sendStatus(200);
  }
);

Replay protection

Store event.id (or the Voxtra-Webhook-Id header, which is the same value) in a short-lived cache (Redis setex with 24h TTL works well). If you see the same ID twice, respond 200 and skip processing.

Retry behavior

Voxtra retries failed deliveries with exponential backoff:

Attempt Delay after previous
1 Immediate
2 5 seconds
3 25 seconds
4 2 minutes
5 10 minutes
6 1 hour
7 6 hours
8 24 hours
After 8 Dead-lettered (no more retries)

Total retry window: approximately 31 hours. After that, the delivery is marked dead_lettered. Check GET /webhook-deliveries to see failed deliveries.

Auto-disable on 410

If your endpoint returns 410 Gone, Voxtra immediately disables the endpoint and stops sending events. Update the endpoint URL (PUT /webhooks/:id) or create a new endpoint to resume delivery.

Any other 4xx response retries per the schedule above (temporary misconfiguration is possible — Voxtra doesn't assume 4xx means "stop forever").

Rotate the signing secret

curl -X POST \
  "https://api.caller.closedit.ai/v1/tenants/tn_abc123/webhooks/twe_abc/rotate-secret" \
  -H "Authorization: Bearer vx_live_your24hexkeyhere000000"

The response includes the new signing_secret once. The old secret is immediately invalid. Update your VOXTRA_WEBHOOK_SECRET env var before rotating in production (use a brief dual-verification window if needed).

Test your endpoint

curl -X POST \
  "https://api.caller.closedit.ai/v1/tenants/tn_abc123/webhooks/twe_abc/test" \
  -H "Authorization: Bearer vx_live_your24hexkeyhere000000"

Returns { ok, http_status, error, excerpt } immediately — useful during development to verify your URL and signature verification before live traffic.


3c. Configure an AI Agent

Agents must exist before dispatch. Create one once per Closedit agent configuration. Use client_ref to link an agent to a Closedit entity and filter later.

Create an agent:

curl -X POST \
  "https://api.caller.closedit.ai/v1/tenants/tn_abc123/agents" \
  -H "Authorization: Bearer vx_live_your24hexkeyhere000000" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Rania - Q2 SDR",
    "instructions": "You are Rania, an outbound sales development representative for Closedit. Your goal is to introduce our product, qualify the lead using BANT criteria, and book a discovery call. Be concise, warm, and professional. Never make promises about pricing without confirming with the sales team.",
    "welcome": "Hi, this is Rania calling from Closedit. Am I speaking with the right person?",
    "voice_id": "db6b0ed5-d5d3-463d-ae85-518a07d3c2b4",
    "client_ref": "closedit_sdr_v2",
    "tool_ids": ["tt_book_tool_id"]
  }'

Response (201):

{
  "id": "ta_f6e5d4c3b2a1",
  "agent": {
    "id": "ta_f6e5d4c3b2a1",
    "name": "Rania - Q2 SDR",
    "instructions": "...",
    "welcome": "Hi, this is Rania...",
    "voice_id": "db6b0ed5-d5d3-463d-ae85-518a07d3c2b4",
    "client_ref": "closedit_sdr_v2",
    "tool_ids": ["tt_book_tool_id"],
    "tools": [{ "id": "tt_book_tool_id", "name": "book_appointment", "enabled": true }]
  }
}

Required fields: name, instructions.

Optional fields:

Update the agent (partial):

curl -X PUT \
  "https://api.caller.closedit.ai/v1/tenants/tn_abc123/agents/ta_f6e5d4c3b2a1" \
  -H "Authorization: Bearer vx_live_your24hexkeyhere000000" \
  -H "Content-Type: application/json" \
  -d '{
    "instructions": "Updated instructions for Q3...",
    "tool_ids": ["tt_book_tool_id", "tt_crm_lookup_id"]
  }'

Reference: OpenAPI paths under tag Agents.


3d. Import a Twilio Phone Number

Auth note: As of ADR-017 (2026-05-15), this endpoint accepts a vx_live_* bearer key OR an operator session cookie. Closedit's backend can call it directly to auto-onboard end-users without an operator in the loop. Bearer-key calls are rate-limited to 10 imports per hour per key. Operator session access is uncapped.

Endpoint: POST /v1/tenants/:tenantId/twilio-imports

Voxtra automatically:

curl (Closedit backend — bearer key):

curl -X POST \
  "https://api.caller.closedit.ai/v1/tenants/tn_abc123/twilio-imports" \
  -H "Authorization: Bearer vx_live_your24hexkeyhere000000" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_number": "+15551230099",
    "account_sid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "auth_token": "your32characterauthtokenhere0000",
    "label": "Closedit Outbound US"
  }'

JS fetch (Closedit backend):

const resp = await fetch(
  `https://api.caller.closedit.ai/v1/tenants/${tenantId}/twilio-imports`,
  {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.VOXTRA_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      phone_number: userTwilioNumber,   // E.164, e.g. "+15551230099"
      account_sid:  userAccountSid,
      auth_token:   userAuthToken,
      label:        `User ${userId} import`,
    }),
  },
);
if (resp.status === 429) {
  // Rate limit: 10 imports/hour per bearer key.
  const retryAfter = parseInt(resp.headers.get('Retry-After') || '3600', 10);
  // Back off and retry with the same Idempotency-Key if set.
  await new Promise(r => setTimeout(r, retryAfter * 1000));
}
const data = await resp.json();
// data.trunk_id is what you pass to POST /dispatch

Response (200):

{
  "phone_number": {
    "number": "+15551230099",
    "tenant_id": "tn_abc123",
    "status": "active",
    "provider_kind": "twilio_pv",
    "twilio_account_id": "twa_...",
    "twilio_trunk_id": "ttr_...",
    "label": "Closedit Outbound US"
  },
  "twilio_account_id": "twa_...",
  "twilio_trunk_id": "ttr_..."
}

Rate limit caveat: 10 imports per hour per bearer key. If Closedit's backend is onboarding many users in a short burst, implement a queue. The 429 response includes a Retry-After header. The key dimension is the API key ID, not the egress IP — so a single Closedit backend IP is not penalised for all its users.

What Voxtra does NOT do:

Reference: OpenAPI paths under tag Twilio Imports.


3e. Unimport a Twilio Phone Number

Endpoint: DELETE /v1/tenants/:tenantId/twilio-imports/:phoneNumber

Releases a previously imported Twilio number: detaches it from the Twilio Elastic SIP Trunk, removes it from the LiveKit inbound trunk's number filter, and marks the DB row as released. Twilio detach and LK update failures are non-fatal (logged and audited) — the DB row is always released if the number exists.

Accepts bearer key or operator session (ADR-017). No per-key rate limit on DELETE — the default application rate limiter applies.

Idempotency: Deleting a never-imported number or an already-released number returns 404. Design your onboarding teardown to expect and swallow 404.

curl:

curl -X DELETE \
  "https://api.caller.closedit.ai/v1/tenants/tn_abc123/twilio-imports/%2B15551230099" \
  -H "Authorization: Bearer vx_live_your24hexkeyhere000000"

Note the URL-encoding: +%2B.

Response (200):

{
  "phone_number": {
    "number": "+15551230099",
    "tenant_id": "tn_abc123",
    "status": "released",
    "provider_kind": "twilio_pv"
  }
}

Reference: OpenAPI paths under tag Twilio Imports.


3f. Embed a Browser-Based Test Call

For "try the agent in your dashboard" flows — Closedit's agency users click a button, talk to Sophie through their browser, no phone number needed. The session uses WebRTC over LiveKit; mic + audio are carried in the page, not over PSTN.

Endpoint:

POST /v1/tenants/{tenantId}/agents/{agentId}/web-session
Authorization: Bearer vx_live_...
Content-Type: application/json

Body (all fields optional):
{
  "lead":       { "name": "Alex", "email": "alex@boulevard.example" },
  "client_ref": "blvd-lead-12345"
}

Response 200:

{
  "room_name":  "infra-test-web-ta_f3078fe295f8-1716123456789",
  "ws_url":     "wss://livekit.closedit.ai",
  "token":      "<JWT, 1h TTL, room-scoped, canPublish+canSubscribe only>",
  "identity":   "web-tester-a7c4f9b2",
  "expires_at": "2026-05-18T14:30:00.000Z"
}

token is a LiveKit AccessToken with grants limited to one room, audio publish + subscribe, no admin. After expires_at the token is invalid; mint a new session if the user wants to try again.

Rate limit: 30 web-session creations per minute per tenant. Standard RFC draft-7 RateLimit-* headers on every response; Retry-After on 429s.

Pattern A — Custom UI (recommended for branded experience)

Install livekit-client@2 in your frontend bundle. Wire up:

import { Room, Track } from 'livekit-client';

// 1. Closedit backend mints a session (server-side):
const { ws_url, token, expires_at } = await fetch(
  `${VOXTRA_BASE}/v1/tenants/${tenantId}/agents/${agentId}/web-session`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${VOXTRA_API_KEY}`,  // server-only — never expose to browser
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ lead: { name: leadName }, client_ref: leadId }),
  },
).then(r => r.json());

// 2. Closedit backend returns ONLY ws_url + token + expires_at to the
//    customer's browser (NOT the bearer key). The token is short-lived
//    and scoped, so it's safe to expose to the user's browser context.

// 3. In the customer's browser:
const room = new Room({ adaptiveStream: true, dynacast: true });

room.on('trackSubscribed', (track) => {
  if (track.kind === Track.Kind.Audio) {
    const audioEl = track.attach();
    audioEl.autoplay = true;
    document.body.appendChild(audioEl);
  }
});

await room.connect(ws_url, token);
await room.localParticipant.setMicrophoneEnabled(true);
// Agent greets the user automatically and the conversation flows from there.

// 4. End-of-call: room emits 'disconnected'. You can show a "Session ended"
//    state in the UI and fetch transcripts via GET /v1/tenants/{id}/calls/{id}
//    after the agent posts its summary (~1-3s after disconnect).

Reference implementation: see admin/public/app.js openTestDrawer() for the in-dashboard test drawer that uses the same pattern.

Tip — use LiveKit's pre-built components. If your dashboard is React, @livekit/components-react gives you <LiveKitRoom>, <AudioVisualizer>, <DisconnectButton>, mic toggle, participant tiles, etc. out of the box — wire serverUrl={ws_url} and token={token} from the Voxtra response and you have a working call UI in 20 lines of JSX. Same packages exist for Vue (@livekit/components-vue) and Svelte. Full SDK docs: docs.livekit.io/reference/components. The raw-SDK example above is only the right path if you need full control over the UI; for typical integrations the components package saves real time.

Pattern B — Iframe (zero-code path)

If you just want "open a panel, talk to Sophie, close the panel" with no custom UI, you can build a launcher URL using LiveKit's hosted Meet UI:

const meetUrl =
  'https://meet.livekit.io/custom?liveKitUrl=' + encodeURIComponent(ws_url) +
  '&token=' + encodeURIComponent(token);

// In the customer's browser:
<iframe src={meetUrl} allow="microphone" width="400" height="600" />

Security note on Pattern B: the LiveKit Meet URL embeds the JWT in the query string. URLs with tokens can leak via browser history, server access logs, Referer headers, and especially any third-party analytics script the hosting page loads (Google Analytics, Intercom, Segment, Mixpanel, etc. all routinely capture document.referrer and full URLs). The token is 1h-bounded and scoped to one room, so the blast radius is contained, but prefer Pattern A for production use cases. Use Pattern B only for short-lived, ephemeral embeds (e.g., internal demo widgets, sales-call sandboxes) where the convenience outweighs the leak surface AND the hosting page has no third-party tracking.

Required microphone permissions

The customer's browser will prompt for microphone access on the first setMicrophoneEnabled(true) call. The page hosting the iframe (Pattern B) or your custom UI (Pattern A) must be served with a Permissions-Policy header that allows microphone for the embedding origin. For iframes:

<iframe ... allow="microphone" />

Without this attribute, the browser blocks getUserMedia() regardless of the user's site permission. We hit this exact bug ourselves and surfaced NotAllowedError: Permission denied even when the user had granted mic access — fix is the allow="microphone" attribute on the iframe.

Lifecycle

Reference: OpenAPI path POST /v1/tenants/{tenantId}/agents/{agentId}/web-session.


3g. Fetch a Post-Call Summary

After every completed call (web-test or PSTN), Voxtra runs a structured extraction pass over the transcript and stores a strict-schema summary covering outcome, contact captured, qualification facts, objections, follow-ups, booking attempt, and per-call metrics. Use it to power the post-call screen in onboarding step 4 and the "what happened on this call" view in the production dashboard.

Endpoint:

GET /v1/tenants/{tenantId}/calls/{callId}/summary
Authorization: Bearer vx_live_...

Response 200 — summary ready:

{
  "call_summary": {
    "id":              "cs_af2440d48c69ee83",
    "call_id":         "AJ_eJTNRqx5N6tp",
    "schema_version":  "1",
    "prompt_version":  "1",
    "vertical_id":     "real_estate",
    "outcome":         "booked",
    "payload":         { /* full structured extraction, see below */ },
    "created_at":      "2026-05-23T18:54:52.502Z"
  }
}

The top-level outcome is the denormalized enum string for cheap filter queries. The full Fact envelope with confidence + evidence lives in payload.outcome.

Response 202 — still generating:

{ "status": "pending", "call_id": "AJ_...", "estimated_ready_at": "..." }

Retry-After header set on 202. Generation runs once per call.completed event and takes 30 to 90 seconds for a typical 4-minute call, with tail to ~110 seconds for longer calls with rich qualification under provider load. Poll every 5 seconds for up to 180 seconds. Or subscribe to the webhook event below and skip polling entirely.

Response 409 — permanent failure:

{
  "call_summary": {
    "status": "failed",
    "failure": {
      "code":    "transcript_empty",
      "message": "Call ended before any agent or user turn was recorded.",
      "retryable": false
    }
  }
}

Treat as a known terminal state. Don't retry. Render an empty-state card. Failure codes: transcript_empty, transcript_too_long, llm_timeout (exhausted), llm_invalid_json (after repair), schema_validation_failed, content_policy_block, unknown.

Payload shape (the part you render)

Every fact is wrapped in a Fact<T> envelope: { value, confidence, evidence_turn_ids, source? }. Null value with confidence: 0 means the conversation did not cover that fact — do not display as zero or false, display as "Not detected" or omit.

const p = response.call_summary.payload;

// Headline + verdict
p.summary.headline               // "Amosama booked an advisor call for Monday"
p.summary.narrative              // 1-2 sentence summary
p.summary.next_best_action       // recommended next action

// Outcome (the verdict pill)
p.outcome.value                  // "booked" | "qualified" | "disqualified" |
                                 // "callback" | "abandoned" | "voicemail" |
                                 // "wrong_number" | "complaint" | "other"
p.outcome.confidence             // 0.0-1.0

// Booking
p.booking.attempted.value
p.booking.succeeded.value
p.booking.scheduled_for.value    // ISO datetime or null
p.booking.channel.value          // "in_person_tour" | "phone" | null

// Contact captured
p.contact.captured.value
p.contact.name.value
p.contact.email.value
p.contact.phone.value            // may have source: "caller_id" if from PSTN
p.contact.consent_to_followup.value

// Intent
p.intent.primary.value           // "buy_home" | "schedule_tour" | "inquire" | ...
p.intent.secondary.value

// Vertical-specific qualification.
//   Discriminator: vertical.id tells you which schema applies to vertical.facts.
//   The facts block always lives under .facts regardless of vertical (single key
//   keeps client code simple). The vertical.id field is what tells you which
//   field set to expect inside facts.
p.vertical.id                    // "real_estate" | "generic" | ...
p.vertical.facts.budget.value             // (real_estate) string like "$650,000" or null
p.vertical.facts.timeline.value           // (real_estate) e.g. "30_to_60_days" or null
p.vertical.facts.area.value               // (real_estate) string or array
p.vertical.facts.pre_approved.value       // (real_estate) bool or null
p.vertical.facts.property_type.value      // (real_estate)
p.vertical.facts.beds.value               // (real_estate)
p.vertical.facts.baths.value              // (real_estate)

// Render pattern: gate the qualification card on vertical.id, then read facts.
// if (p.vertical.id === 'real_estate') renderRealEstateCard(p.vertical.facts);
// else if (p.vertical.id === 'lending') renderLendingCard(p.vertical.facts);
// else renderGenericCard(p.vertical.facts);

// Objections (caller pushbacks the agent encountered)
p.objections                     // array of { category, quote, resolved, evidence_turn_ids }

// Follow-ups (what the human owner should do next)
p.followups                      // array of { task, owner, due_by, evidence_turn_ids }

// Metrics
p.metrics.talk_ratio.value
p.metrics.interruptions.value
p.metrics.dead_air_seconds.value
p.metrics.sentiment_overall.value          // "positive" | "neutral" | "negative"

// System-emitted warnings (top-banner candidates)
p.warnings                       // array of strings:
                                 // "transcript_very_short", "low_confidence_outcome",
                                 // "contact_not_captured", "metrics_unavailable", etc.

// Provenance (for QA + "show transcript citations")
p.transcript_provenance.total_turns
p.transcript_provenance.turns_referenced   // array of turn indices the LLM cited

The full JSON Schema is served at caller.closedit.ai/docs/schemas/call-summary/v1.json. Use it for codegen.

Confidence display rule (calibrated by the LLM, trust it)

confidence Render as
>= 0.85 Value, no badge
0.6 - 0.85 Value plus a small amber "uncertain" pill
< 0.6 or value === null "Not detected" greyed out, or omit the row

Don't display fields with confidence: 0. They are unknown, not absent.

Polling pattern (v1)

async function fetchSummary(callId: string, opts: { maxWaitMs?: number } = {}) {
  const start = Date.now();
  // 180s budget covers the 90th-percentile generation tail (Llama 3.3 70B on
  // DeepInfra under load). Production clients should use the webhook event
  // instead; this polling pattern is for the onboarding test screen.
  const maxWaitMs = opts.maxWaitMs ?? 180_000;
  while (Date.now() - start < maxWaitMs) {
    const resp = await fetch(
      `${VOXTRA_BASE}/v1/tenants/${TENANT_ID}/calls/${callId}/summary`,
      { headers: { Authorization: `Bearer ${VOXTRA_API_KEY}` } },
    );
    if (resp.status === 200) return (await resp.json()).call_summary;
    if (resp.status === 409) {
      const body = await resp.json();
      return { failed: true, ...body.call_summary };
    }
    if (resp.status === 202) {
      const retryAfter = parseInt(resp.headers.get('retry-after') || '5', 10);
      await new Promise(r => setTimeout(r, retryAfter * 1000));
      continue;
    }
    throw new Error(`unexpected status ${resp.status}`);
  }
  throw new Error('summary timeout');
}

Pair the summary with the call recording

Every call (web-test, inbound PSTN, outbound dispatch) is recorded to R2 via LiveKit Egress. The recording is the highest-trust artifact on the post-test screen: structured facts prove the AI extracted the right data, but the recording proves the conversation felt real. Brokers and operators will share it internally to justify the spend. Render an audio player above the structured cards on the post-test panel.

The recording URL lives on the existing call detail endpoint, not the summary endpoint. Fetch them in parallel.

GET /v1/tenants/{tenantId}/calls/{callId}
Authorization: Bearer vx_live_...

Response shape (note recording_url and recording_missing are TOP-LEVEL fields alongside call, not nested inside it):

{
  "call": {
    "id":               "AJ_PagBecgFvhjQ",
    "duration_sec":     225,
    "status":           "completed",
    "recording_key":    "web-tests/AJ_PagBecgFvhjQ.mp4",
    "recording_status": "completed",
    "egress_id":        "EG_xXZvPiG8Apkp",
    ...
  },
  "turns":             [ ... ],
  "transcripts":       [ ... ],
  "recording_url":     "https://fsn1.your-objectstorage.com/voxtra-recordings/web-tests/AJ_PagBecgFvhjQ.mp4?X-Amz-Signature=...",
  "recording_missing": false
}

recording_url is a short-TTL (1 hour) signed R2 URL. Browsers can play it directly via HTML5 audio with no Closedit-backend proxy needed.

Polling semantics: the URL is null while LiveKit Egress is still finalizing the file in R2 (5 to 30 seconds after call end). Poll the same endpoint until recording_url is non-null. If recording_missing becomes true, the egress job failed and there is no recording to fetch (rare but real).

Timing: recording lands before the summary

LiveKit Egress finalizes the recording within 5 to 30 seconds of call end. The summary takes 30 to 110 seconds. Show them progressively:

  1. t+0s — call ends. Poll both endpoints.
  2. t+5-30s — recording URL appears. Render the audio player immediately.
  3. t+30-110s — summary lands. Render the structured cards below the player.

Don't wait for the summary before showing the recording. The user wants to hear the call as soon as possible; the structured facts are secondary.

Minimal player markup

// data = await fetch(...).then(r => r.json())
// data.recording_url is at the response envelope top level, NOT data.call.recording_url
{data?.recording_url ? (
  <div className="post-test-recording">
    <audio src={data.recording_url} controls preload="metadata" />
    <a href={data.recording_url} download className="download-link">
      Download
    </a>
  </div>
) : data?.recording_missing ? (
  <div className="recording-failed">
    Recording is not available for this call.
  </div>
) : (
  <div className="recording-pending">
    Recording will appear here in a few seconds…
  </div>
)}

Don't proxy the audio through your backend. Signed R2 URLs are scoped, short- lived, and work in any modern browser. Adding a proxy doubles the bandwidth cost on Closedit's side for zero gain.

For waveform visualization, transcript-sync highlighting, or seeking to specific tool-call moments, ship later. v1 is <audio controls> + Download.

Production pattern: subscribe to the webhook event

Polling is fine for the onboarding test screen. For the production dashboard, subscribe to the call.summary.ready webhook event in your tenant webhook configuration. The delivery body is a small reference:

{
  "id":         "evt_01HW...",
  "type":       "call.summary.ready",
  "created_at": "2026-05-23T14:22:09Z",
  "tenant_id":  "tn_...",
  "data": {
    "call_id":         "AJ_...",
    "summary_id":      "cs_...",
    "status":          "ready",
    "schema_version":  1,
    "prompt_version":  7,
    "outcome":         "booked",
    "vertical_id":     "real_estate",
    "url":             "https://api.caller.closedit.ai/v1/tenants/.../calls/.../summary",
    "generated_at":    "2026-05-23T14:22:08Z"
  }
}

The full summary body is not inline. Receivers route on data.outcome plus data.vertical_id and fetch the full body on demand from the url. This keeps webhook payloads small at million-call volumes and matches the pattern used for other call lifecycle events.

For status: "failed" deliveries, outcome is replaced by failure_code and vertical_id is omitted.

Empty and partial state handling

Trigger semantics

Voxtra triggers summary generation automatically on call.completed for every call. You can also force a fresh run with POST :rerun:

POST /v1/tenants/{tenantId}/calls/{callId}/summary:rerun
Authorization: Bearer vx_live_...
Content-Type: application/json

{ "reason": "manual_qa" }

Idempotency-Key honored. Rate limit: 5 reruns per call per hour, 100 per tenant per hour. Useful for QA replays and prompt iteration. Production clients almost never need this.

Don't

Reference: OpenAPI paths GET /v1/tenants/{tenantId}/calls/{callId}/summary and POST /v1/tenants/{tenantId}/calls/{callId}/summary:rerun. JSON Schema at /docs/schemas/call-summary/v1.json.

Custom extraction fields (schema v2)

When a tenant has active custom field definitions, the summarization worker automatically routes to schema v2. The GET .../summary response then includes a custom block alongside the standard payload:

{
  "call_summary": {
    "schema_version": "2",
    "payload": { "...standard v1 fields..." },
    "custom": {
      "spec_hash": "9af3b2c1d4e5f6a7...",
      "facts": {
        "lead_type":   { "value": "buyer",  "confidence": 0.97, "evidence_turn_ids": [3] },
        "timeline":    { "value": "1_3_months", "confidence": 0.82, "evidence_turn_ids": [7, 9] },
        "bedrooms":    { "value": null, "confidence": 0, "evidence_turn_ids": [] }
      },
      "definitions": [
        { "field_key": "lead_type", "type": "enum", "label": "Lead type", "crm_hint": "lead_type", ... },
        { "field_key": "timeline",  "type": "enum", "label": "Timeline",  "crm_hint": "timeline",  ... },
        { "field_key": "bedrooms",  "type": "number", "label": "Bedrooms", "crm_hint": "bedrooms", ... }
      ]
    }
  }
}

custom is null on schema v1 responses (no active custom fields at extract time).

CRM push pattern:

const { custom } = callSummary;
if (custom) {
  const crmPayload = {};
  for (const def of custom.definitions) {
    const fact = custom.facts[def.field_key];
    if (fact && fact.value !== null) {
      crmPayload[def.crm_hint] = fact.value;
    }
  }
  // crmPayload: { lead_type: "buyer", timeline: "1_3_months" }
  await pushToCrm(crmPayload);
}

spec_hash identifies the field definition snapshot used at extract time. Store it with your CRM record if you need to detect schema drift across reruns.

Custom field onboarding flow

The recommended onboarding sequence for a new agency end-user inside Closedit:

  1. GET starter pack (preview, read-only):

    GET /v1/tenants/{tenantId}/custom-fields/starter-pack?vertical=real_estate
    Authorization: Bearer vx_live_...
    

    Returns the full pack. Render a checkbox list in your UI. All boxes checked by default; let the user deselect fields they don't need.

  2. POST apply-starter-pack (seeds the tenant):

    POST /v1/tenants/{tenantId}/custom-fields/apply-starter-pack
    Authorization: Bearer vx_live_...
    Content-Type: application/json
    
    { "vertical": "real_estate", "field_keys": ["lead_type", "timeline", "is_working_with_agent"] }
    

    Response:

    { "created": ["lead_type", "timeline", "is_working_with_agent"], "skipped_existing": [], "rejected": [] }
    

    Idempotent: re-running with the same body is safe. Existing keys go to skipped_existing and created is empty. This means your onboarding code can call apply on every re-entry without guarding for duplicates.

  3. POST /v1/tenants/{tenantId}/custom-fields (optional user-defined fields):

    {
      "field_key": "appointment_type",
      "type": "enum",
      "label": "Appointment type",
      "enum_values": ["in_person", "virtual", "phone", "undecided"],
      "required": false,
      "crm_hint": "appointment_type"
    }
    
  4. From the next call onward, the summarization worker detects the active custom fields and routes to v2 automatically. No additional configuration.

Cap and idempotency contract:


4. Error Handling

Standard error shape:

{
  "error": "machine_readable_code",
  "message": "Human-readable description.",
  "details": {}
}

The details field is optional and present only for structured errors (e.g., unknown_tool_ids includes unknown_tool_ids: [...]).

Retry semantics:

Status Meaning Action
400 Bad request — validation failed Fix the request. Do not retry as-is.
401 Missing or invalid API key Check the key. May indicate rotation needed.
403 Key/tenant mismatch or feature flag off Check BYO_TRUNK_API_KEY_ENABLED or key scope.
404 Resource not found Verify IDs. Resource may have been deleted.
409 Conflict — e.g., number already in use Do not retry. Either the resource exists or there's a real conflict.
422 Idempotency-Key reused with different body Do not retry with this key. Generate a new key for a new attempt.
429 Rate limit exceeded Back off and retry after the Retry-After header.
500 Internal server error Retry with exponential backoff.
502 Upstream failure (LiveKit or Twilio API) Retry after a delay. For trunk create, check if the trunk was created before retrying.

Common error codes:

error code Route Meaning
invalid_phone_format dispatch, phone-numbers, twilio-imports E.164 violation
missing_required_fields all mutating routes Required body field absent
trunk_not_found dispatch, trunks Trunk doesn't exist, wrong tenant, or archived
agent_not_found dispatch, agents Agent doesn't exist or wrong tenant
phone_already_in_use phone-numbers, twilio-imports Number registered to another tenant (generic — no tenant details leaked)
third_party_trunk_conflict twilio-imports Number attached to a Twilio trunk Voxtra doesn't manage
twilio_credentials_invalid twilio-imports Bad Twilio SID/token
trunk_has_active_numbers trunks DELETE Release phone numbers before archiving trunk
tool_name_conflict tools Tool with that name already exists for this tenant

5. Idempotency and Rate Limits

5a. Idempotency-Key header

Voxtra supports Stripe-style idempotency on all five POST mutation endpoints:

Include Idempotency-Key: <uuid> on the request. Generate one UUID per logical attempt and keep the same key on retries. The server caches the first non-5xx response for 24 hours (keyed by tenant_id + Idempotency-Key) and replays it with Idempotent-Replayed: true on subsequent requests.

Semantics:

Scenario Result
First request with key Processes normally, caches response
Retry with same key + same body Returns cached response + Idempotent-Replayed: true
Retry with same key + different body 422 idempotency_key_in_use
No Idempotency-Key header Processes normally (no caching)
5xx response Not cached — retry re-processes
4xx response Cached — same error replayed on retry

curl example:

curl -X POST \
  "https://api.caller.closedit.ai/v1/tenants/tn_abc123/dispatch" \
  -H "Authorization: Bearer vx_live_your24hexkeyhere000000" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
  -d '{
    "trunk_id": "tt_a1b2c3d4e5f6",
    "agent_id": "ta_f6e5d4c3b2a1",
    "to_number": "+15551234567",
    "client_ref": "lead_closedit_789"
  }'

JS fetch with retry pattern:

import { randomUUID } from 'crypto';

async function dispatchWithRetry(tenantId, payload, { maxRetries = 3 } = {}) {
  // Generate key once per logical attempt — reuse on retries.
  const idempotencyKey = randomUUID();

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const res = await fetch(
      `https://api.caller.closedit.ai/v1/tenants/${tenantId}/dispatch`,
      {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${process.env.VOXTRA_API_KEY}`,
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey,
        },
        body: JSON.stringify(payload),
      }
    );

    // Success or a deterministic 4xx: stop retrying.
    if (res.ok || res.status < 500) {
      const data = await res.json();
      if (res.headers.get('Idempotent-Replayed') === 'true') {
        console.log('Response was replayed from idempotency cache');
      }
      return data;
    }

    // 5xx: check Retry-After and back off.
    if (attempt < maxRetries - 1) {
      const retryAfter = parseInt(res.headers.get('Retry-After') || '5', 10);
      await new Promise(r => setTimeout(r, retryAfter * 1000));
    }
  }

  throw new Error(`dispatch failed after ${maxRetries} attempts`);
}

See also: Stripe idempotency guide for the pattern Voxtra follows.

5b. Rate-limit response headers

Every response includes standard rate-limit headers (IETF draft-7):

Header Meaning
RateLimit-Limit Max requests in the current window
RateLimit-Remaining Remaining requests in the current window
RateLimit-Reset Seconds until the window resets
RateLimit-Policy Policy descriptor string
Retry-After Seconds to wait before retrying (429 only)

429 handling:

if (res.status === 429) {
  const retryAfter = parseInt(res.headers.get('Retry-After') || '60', 10);
  const err = await res.json();
  // err.error === 'rate_limit_exceeded'
  // err.details.retry_after_seconds mirrors Retry-After
  await new Promise(r => setTimeout(r, retryAfter * 1000));
  // retry with the same Idempotency-Key if you set one
}

Rate limit table:

Endpoint Limit Window Key dimension
POST /admin/api/login 10 15 minutes Per IP
POST /admin/api/tenants (operator-only) 10 1 hour Per IP
POST /v1/tenants/:id/twilio-imports (bearer key) 10 1 hour Per bearer key ID
POST /v1/tenants/:id/twilio-imports (operator session) uncapped
POST /admin/api/internal/livekit-webhook 600 1 minute Per IP
All other endpoints Not rate-limited at application layer

Note: the Twilio imports limiter keys on the API key ID, not the egress IP — so a single Closedit backend IP is not penalised for all its tenants.

The BYO_TRUNK_API_KEY_ENABLED environment variable gates API-key access to trunk and phone-number CRUD. When this flag is false, bearer-key callers receive 403 on those routes even with a valid key. Confirm with the operator that the flag is set to true for production.

5c. Idempotent vs. non-idempotent endpoints

Always idempotent (safe to retry freely):

Idempotency-Key required for safe retry:

Without Idempotency-Key, check before retry:


6. Multi-Tenant Data Model

Voxtra's tenant model maps 1:1 to Closedit's:

Closedit workspace  ──→  Voxtra tenant (tn_<hex>)
Closedit API key    ──→  Voxtra API key (vx_live_<hex>)

Voxtra does not have a concept of sub-tenants or organizations. If Closedit has multiple workspaces that should be isolated from each other (separate numbers, agents, call histories), each needs its own Voxtra tenant and API key.

Tenant IDs are stable across the lifetime of the tenant. They appear in every call record, booking, audit event, and webhook payload. Use them as the foreign key in Closedit's database when storing Voxtra-originated data.

client_ref pass-through:

All dispatch, phone-number, and booking records accept a client_ref string. This is an opaque Closedit-side identifier (lead ID, campaign ID, booking ID, etc.) stored as-is on the Voxtra side. It is:

Use client_ref on dispatch requests to correlate Voxtra call records with Closedit leads/campaigns without maintaining a separate join table.


7. Test Environment

There is no dedicated staging environment at this time. Options:

Option A — Sandbox tenant on production

The operator can create a closedit-sandbox tenant on the production Voxtra instance and provision a separate API key. Call records, agents, and trunks are fully isolated by tenant. This is the recommended approach until a staging server exists.

Option B — Twilio magic test numbers

For dispatch tests that should not place real PSTN calls, Twilio provides magic numbers that return specific responses without billing:

Number Behavior
+15005550006 Succeeds — call completes normally
+15005550001 Invalid/unroutable number
+15005550003 Unavailable (returns busy)

These only work with Twilio test credentials (ACtest... SID). Use them when testing the dispatch → call record flow end-to-end.

Option C — Local mock

Stub https://caller.closedit.ai with a local Express server that returns the same response shapes as the OpenAPI spec. Use msw or nock for Closedit-side unit tests.

Notes on phone-test endpoints:

The admin UI exposes POST /v1/tenants/:id/agents/:agentId/phone-test for ad-hoc testing (same auth as the agent endpoints). This routes through the tenant's configured trunk — suitable for verifying a new agent configuration against a real number before going live.


8. Versioning and URL Stability

Canonical base URL: https://api.caller.closedit.ai/v1

Code all clients against this URL. /v1/ is the only version. Future breaking changes will ship as /v2/. Non-breaking additions (new optional fields, new endpoints) are added to /v1/ directly.

Legacy paths (dual-mounted, sunset 2026-08-14):

The old /admin/api/tenants/{tenantId}/* paths remain available and serve responses directly — there is no redirect. However, every response on a legacy path includes the following deprecation headers per RFC 8594:

Deprecation: true
Sunset: Fri, 14 Aug 2026 00:00:00 GMT
Link: <https://api.caller.closedit.ai/v1/tenants/{id}/...>; rel="successor-version"

Migrate all clients to /v1/ before 2026-08-14. After that date the legacy mount will be removed and those paths will return 410 Gone.

To act on the Sunset header in a fetch client:

const res = await fetch(`${VOXTRA_LEGACY_BASE}/admin/api/tenants/${id}/agents`, {
  headers: { Authorization: `Bearer ${apiKey}` },
});
const sunset = res.headers.get('Sunset');
if (sunset) {
  const sunsetDate = new Date(sunset);
  if (sunsetDate < new Date()) {
    // Already past sunset — this path should have been migrated.
    console.error('Voxtra legacy path is past its sunset date. Update to /v1/.');
  } else {
    console.warn(`Voxtra legacy path deprecated. Migrate before ${sunsetDate.toDateString()}.`);
  }
}

URL stability note: Never hardcode /admin/api in client code. Use an env var:

// Code clients to use an env var for the base URL.
// The /v1/* paths are the stable, permanent API surface.
const VOXTRA_API = process.env.VOXTRA_API_URL ?? 'https://api.caller.closedit.ai/v1';

// Use typed paths from generated openapi-typescript types
const res = await fetch(`${VOXTRA_API}/tenants/${tenantId}/dispatch`, ...);

How to stay informed: There is no formal change notification channel. The operator (zaroual.zaroual.oussama@gmail.com) announces breaking changes directly to the Closedit engineering team.

Current behavior invariants (as of Wave 4 — 2026-05-16):