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": "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:

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.


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):