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:
- Log into the Voxtra admin UI at
https://caller.closedit.ai/admin. - Navigate to Tenants and select or create the tenant for Closedit.
- Open the API Keys tab for that tenant.
- Click Generate Key and provide a label (e.g.
closedit-production). - 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:
- Bearer auth:
Authorization: Bearer vx_live_<key>. All tenant-scoped routes require this header, including Twilio Imports (ADR-017 — see section 3d/3e). Operator session cookie is also accepted on all tenant-scoped routes. - Tenant-scoped paths: Every path includes
/:tenantId/. The key must belong to that tenant, otherwise 403. - Error shape: All errors are
{ error: string, message: string, details?: object }. Theerrorfield is a machine-readable code;messageis human-readable. - E.164 enforcement: All phone numbers must be
+followed by a non-zero digit, then 6–14 more digits (7–15 total). The regex is^\+[1-9]\d{6,14}$.
3. The Five Flows Closedit Cares About
3a. Place an Outbound Call
Before dispatching, you need:
- A tenant (confirmed from the operator)
- A SIP trunk (
trunk_id) — created viaPOST /trunksor imported via Twilio Imports - An AI agent (
agent_id) — created viaPOST /agents
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": "cgSgspJ2msm6clMCkdW9",
"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": "cgSgspJ2msm6clMCkdW9",
"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:
welcome— first utterance (default: generic greeting)voice_id— ElevenLabs or Cartesia voice ID (get the list fromGET /admin/api/tts/voicesvia operator session)tool_ids—null= inherit all tenant tools;[]= no tools;[id,...]= explicit list. Omittingtool_idsdefaults tonull(inherits all).client_ref— Closedit-side correlation ID
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:
- Validates Twilio credentials (Account SID + Auth Token)
- Verifies number ownership in the Twilio account
- Creates a Twilio Elastic SIP Trunk for the tenant (idempotent on re-import)
- Creates a Twilio Credential List + credential for SIP DIGEST auth
- Creates a LiveKit outbound SIP trunk
- Persists all DB rows atomically (rolls back on any failure)
- Returns the phone number row linked to the new trunk
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:
- Restore the previous Twilio voice URL on release (ADR-015 §3 — inbound now routes through the SIP trunk, not TwiML).
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.
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:
POST /v1/tenants/:id/dispatchPOST /v1/tenants/:id/agentsPOST /v1/tenants/:id/trunksPOST /v1/tenants/:id/phone-numbersPOST /v1/tenants/:id/twilio-imports
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):
GET— all readsPUTon agents, phone numbers, trunks — deterministic updates
Idempotency-Key required for safe retry:
POST /dispatch,POST /agents,POST /trunks,POST /phone-numbers,POST /twilio-imports— useIdempotency-Keyto avoid duplicates on network timeout/retry.
Without Idempotency-Key, check before retry:
POST /dispatchwithout key: checkGET /tenants/:id/calls?client_ref=<ref>before retrying to confirm no call was already created.POST /agentswithout key: useclient_ref+ list to detect duplicates.
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:
- Returned in call list/detail responses
- Filterable on call/booking list endpoints (
?client_ref=<value>) - Written to the audit log
- Never interpreted by Voxtra
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):
- Error shape
{ error, message, details? }is stable. - E.164 enforcement is uniform across all phone fields.
client_refpass-through is stable.call_idin dispatch responses is the LiveKit agent dispatch job ID, not a Voxtra-minted UUID. Format can vary (AJ_<hex>or LiveKit's native ID). Treat it as an opaque string.