Search by

Official PHP SDK for the Callstorm external API (outbound voice campaigns).

v0.3.0 2026-10-18 22:28 UTC

This package is auto-updated.

Last update: 2026-10-18 23:46:27 UTC


README

SDK oficial para integrar la API externa de Callstorm (campañas de voz salientes: audio, voicebot y agente IA) desde cualquier aplicación PHP 8.1+. Cubre todas las rutas de https://api.callstorm.ai/v1/ext, la paginación por cursor, la idempotencia, el límite de tasa, el mapeo de errores a excepciones y la verificación de webhooks.

  • Sin dependencias en tiempo de ejecución más allá de ext-curl y ext-json.
  • Namespace PSR-4 Callstorm\, tipado estricto, compatible con PHP 8.1, 8.2 y 8.3.
  • Transporte HTTP intercambiable (interfaz Callstorm\Http\Transport) para usar Guzzle, PSR-18 o un fake en tests.

Referencia de la API: callstorm.ai/developers y el documento OpenAPI https://api.callstorm.ai/v1/public/openapi.json.

Índice

  1. Instalación
  2. Inicio rápido
  3. Autenticación y scopes
  4. Recursos
  5. Paginación
  6. Idempotencia
  7. Límite de tasa
  8. Errores
  9. Subida de listas de contactos
  10. Webhooks
  11. Consola de agente en tu CRM (widget)
  12. Buenas prácticas
  13. Versionado y compatibilidad

Instalación

Requisitos: PHP ≥ 8.1 con ext-curl y ext-json (ambas vienen activadas en casi todas las distribuciones).

composer require callstorm/sdk

El paquete está en Packagist; el código fuente, en github.com/dvalenci/callstorm-sdk-php.

Inicio rápido

<?php

require 'vendor/autoload.php';

use Callstorm\Client;

$client = new Client(getenv('VOXCORE_API_KEY')); // vx_live_… en producción, vx_test_… en dev

// 1. Subir el archivo de contactos y crear la lista (cada columna salvo el teléfono viaja como variable).
$list = $client->leadLists()->createFromFile('deudores.csv', [
    'name'          => 'Deudores septiembre',
    'country'       => 'CL',
    'columnMapping' => ['telefono' => 'phone', 'nombre' => 'nombre', 'deuda' => 'monto'],
    'consent'       => true, // declaras que tienes base legal para llamar a cada número
]);
$list = $client->leadLists()->waitUntilReady($list['leadListId']);

// 2. Crear la campaña a partir de una plantilla (diseño) y lanzarla.
$campaign = $client->campaigns()->create([
    'general' => [
        'name' => 'Cobranza sept', 'type' => 'VOICEBOT', 'language' => 'es',
        'trunkId' => 'trk-…', 'callerId' => '+56912345678',
        'schedule' => ['timezone' => 'America/Santiago', 'windowStart' => '09:00', 'windowEnd' => '20:00', 'days' => [1, 2, 3, 4, 5]],
        'dialingRules' => ['maxAttempts' => 3, 'retryIntervalMin' => 60, 'maxConcurrency' => 10],
    ],
    'templateId' => 'tpl-…',
    'leadListId' => $list['leadListId'],
], 'crm-batch-2026-09-27'); // clave de idempotencia opcional

$estimate = $client->campaigns()->estimate($campaign['campaignId']);
if ($estimate['canLaunch']) {
    $client->campaigns()->launch($campaign['campaignId']);           // o launch($id, '2026-09-28T12:00:00Z')
}

// 3. Resultados: paginar llamadas, exportar o recibir webhooks.
foreach ($client->calls()->list(campaignId: $campaign['campaignId'], limit: 100) as $call) {
    echo "{$call['phone']} {$call['disposition']} {$call['resultCode']}\n";
}
$job = $client->exports()->create('CSV', ['campaignId' => $campaign['campaignId']]);
$job = $client->exports()->waitUntilDone($job['jobId']);
echo $job['fileUrl'];

El flujo completo con manejo de errores está en examples/run-campaign.php.

Opciones del cliente

$client = new Client('vx_live_…', [
    'baseUrl'        => 'https://api-dev.callstorm.ai/v1/ext', // por defecto https://api.callstorm.ai/v1/ext
    'timeout'        => 30,     // segundos por petición
    'connectTimeout' => 10,     // segundos para conectar
    'maxRetries'     => 2,      // reintentos tras el primer intento (ver "Buenas prácticas")
    'userAgent'      => 'mi-crm/2.0 callstorm-php/0.1.0',
    'useBearer'      => false,  // true => Authorization: Bearer vx_… en lugar de X-Api-Key
    'transport'      => null,   // instancia de Callstorm\Http\Transport (por defecto CurlTransport)
]);

Todas las respuestas se devuelven como arrays asociativos con las mismas claves que el JSON de la API.

Autenticación y scopes

Las claves se crean en Settings › Integrations › API keys de la aplicación web (permiso settings:integrations). El token (vx_live_… en producción, vx_test_… en el resto de ambientes) se muestra una sola vez.

El SDK envía X-Api-Key: vx_… por defecto; con useBearer => true envía Authorization: Bearer vx_….

Cada clave tiene scopes (los mismos permisos que un usuario). Si falta el scope que requiere una ruta, la API responde 403 FORBIDDEN y el SDK lanza ForbiddenException; requiredScope() indica cuál falta cuando la API lo incluye. Scopes usados por los recursos:

Scope Rutas
campaign:read listar/leer campañas, estimación, llamadas
campaign:write crear, editar, borrar, pausar, reanudar, detener, archivar, duplicar campañas
campaign:launch lanzar campañas
templates:write crear, editar, borrar, duplicar plantillas
leads:write presign de archivos, crear y borrar listas
dnc:write añadir y quitar números de la lista de no llamar
reports:export listar y crear exportaciones
settings:telephony troncales SIP, caller IDs, agentes y el token de sesión del widget de agente
settings:integrations webhooks y claves de API
credits:buy iniciar un checkout de créditos

Una clave de API nunca puede crear ni revocar claves: apiKeys() está pensado para sesiones de usuario con ese permiso.

$me = $client->me();           // ['user' => […], 'account' => […]]
$dash = $client->dashboard();  // cifras del panel

Recursos

Cada recurso se obtiene desde el cliente y sus métodos corresponden 1:1 con una ruta. Los métodos de escritura aceptan un último parámetro opcional $idempotencyKey.

Campañas — $client->campaigns()

$campaigns = $client->campaigns();

$page = $campaigns->list(status: 'RUNNING', type: 'VOICEBOT', from: '2026-09-01', to: '2026-09-30', q: 'cobranza', limit: 50);
$c    = $campaigns->create($draftInput, 'idem-1');          // POST /campaigns (estado DRAFT)
$c    = $campaigns->get('cmp-1');                           // GET  /campaigns/{id}
$c    = $campaigns->update('cmp-1', $draftInput);           // PUT  /campaigns/{id} (solo DRAFT)
$campaigns->delete('cmp-1');                                // DELETE /campaigns/{id}
$e    = $campaigns->estimate('cmp-1');                      // ['micros', 'canLaunch', 'reasons', …]
$c    = $campaigns->launch('cmp-1');                        // ahora
$c    = $campaigns->launch('cmp-1', '2026-09-28T12:00:00Z', 'launch-1'); // programada
$c    = $campaigns->pause('cmp-1');
$c    = $campaigns->resume('cmp-1');
$c    = $campaigns->stop('cmp-1');
$c    = $campaigns->archive('cmp-1');
$copy = $campaigns->duplicate('cmp-1');

$draftInput sigue el esquema CampaignDraftInput del OpenAPI: general (nombre, tipo, idioma, troncal, caller ID, schedule, dialingRules), y templateId o config inline, más leadListId.

Plantillas — $client->templates()

$templates = $client->templates();
$all = $templates->list();                   // array completo (sin paginar)
$ai  = $templates->list('AI_AGENT');         // filtra por tipo: AUDIO | VOICEBOT | AI_AGENT
$t   = $templates->create(['type' => 'VOICEBOT', 'name' => 'Recordatorio', 'language' => 'es', 'payload' => $config]);
$t   = $templates->get('tpl-1');
$t   = $templates->update('tpl-1', ['name' => 'Nuevo nombre']);   // PATCH (parcial)
$templates->delete('tpl-1');                                      // 409 IN_USE si una campaña la usa
$t   = $templates->duplicate('tpl-1');

Listas de contactos — $client->leadLists()

Varios teléfonos por contacto: mapea las columnas adicionales como phone2 … phone5 en columnMapping; la campaña los marca en orden mientras no haya contacto (dialingRules.maxAttempts aplica por número).

$lists = $client->leadLists();
$all  = $lists->list();                                  // array completo
$key  = $lists->uploadFile('deudores.csv');              // presign + PUT; devuelve sourceFileKey
$l    = $lists->create(['name' => '…', 'country' => 'CL', 'sourceFileKey' => $key, 'columnMapping' => […], 'consent' => true]);
$l    = $lists->createFromFile('deudores.xlsx', ['name' => '…', 'country' => 'CL', 'columnMapping' => […]]);
$l    = $lists->get('ll-1');                             // status: UPLOADED | PROCESSING | READY | ERROR
$l    = $lists->waitUntilReady('ll-1', timeoutSec: 300, intervalSec: 3);
$lists->delete('ll-1');
foreach ($lists->leads('ll-1', limit: 500) as $lead) { … }   // paginado

Archivos — $client->files()

$files = $client->files();
$p   = $files->presign('audio', 'saludo.mp3', 'audio/mpeg', filesize('saludo.mp3')); // ['uploadUrl', 'key', …]
$key = $files->upload('saludo.mp3', 'audio');            // presign + subida; devuelve key

Lista de no llamar — $client->dnc()

$dnc = $client->dnc();
foreach ($dnc->list(q: '+569', limit: 200) as $entry) { … }    // paginado
$dnc->add(['+56912345678', ['phoneE164' => '+56987654321', 'reason' => 'Solicitud del cliente']]);
$dnc->remove('+56912345678');

Llamadas — $client->calls()

$calls = $client->calls();
$paginator = $calls->list(
    campaignId: 'cmp-1', type: null, from: '2026-09-01T00:00:00Z', to: null,
    disposition: 'ANSWERED', resultCode: 'PROMISE', phone: null, sort: Calls::SORT_NEWEST, limit: 100
);
$detail = $calls->get('call-1');   // CallDetail: transcript, answers, dtmfEvents, recordingUrl (prefirmada, 5 min)

Exportaciones — $client->exports()

$exports = $client->exports();
$jobs = $exports->list();
$job  = $exports->create('CSV', ['campaignId' => 'cmp-1', 'from' => '2026-09-01', 'to' => '2026-09-30']);
$job  = $exports->get($job['jobId']);                 // PENDING | PROCESSING | READY | ERROR
$job  = $exports->waitUntilDone($job['jobId']);       // devuelve el job READY con fileUrl

Cuenta: celdas, troncales y caller IDs — $client->account()

$account = $client->account();
$cells  = $account->cells();
$trunks = $account->trunks();
$t = $account->createTrunk(['name' => 'Proveedor', 'ip' => '203.0.113.10', 'channels' => 30, 'cps' => 5]);
$t = $account->trunk('trk-1');
$t = $account->updateTrunk('trk-1', ['channels' => 60]);
$account->deleteTrunk('trk-1');
$r = $account->testTrunk('trk-1');                       // ['ok' => true, 'latencyMs' => 42]
$ids = $account->callerIds();
$c = $account->createCallerId('+56229876543', 'CL', 'trk-1', 'Principal');
$c = $account->updateCallerId('+56229876543', 'trk-2', 'Backup');
$account->deleteCallerId('+56229876543');

Créditos — $client->credits()

$credits = $client->credits();
foreach ($credits->ledger(kind: 'CONSUMPTION', limit: 100) as $entry) { … }   // paginado
$payments = $credits->payments();
$packages = $credits->packages();
$pricing  = $credits->pricing();
$checkout = $credits->checkout('pkg-1');   // ['checkoutUrl' => 'https://checkout.stripe.com/…']

Webhooks — $client->webhooks()

$hooks = $client->webhooks();
$list = $hooks->list();
$hook = $hooks->create('https://mi-crm.com/webhooks/voxcore', [Webhooks::EVENT_CALL_COMPLETED, Webhooks::EVENT_LEAD_LIST_PROCESSED]);
$secret = $hook['secret'];   // ¡se devuelve una sola vez! guárdalo para verificar firmas
$hooks->delete($hook['webhookId']);

Claves de API — $client->apiKeys()

$keys = $client->apiKeys();
$all = $keys->list();
$key = $keys->create('CRM', ['campaign:read', 'campaign:write'], expiresAt: '2027-01-01T00:00:00Z', rateLimitPerMin: 1200);
$token = $key['token'];      // se devuelve una sola vez
$keys->delete($key['keyId']);

Agentes y consola embebida — $client->agents()

$agents = $client->agents();
$all = $agents->list();                                            // presencia incluida (status, pauseReason, campaignId, currentCall…)
$ana = $agents->create(['name' => 'Ana Pérez', 'email' => 'ana@empresa.cl', 'extension' => '101'], 'agent-ana');
$agents->update($ana['agentId'], ['extension' => '102']);
$agents->setStatus($ana['agentId'], 'PAUSED', 'SUPERVISOR');      // supervisor: AVAILABLE | PAUSED (+ motivo) | OFFLINE
$session = $agents->sessionToken($ana['agentId'], 8 * 3600);      // token para la consola embebida (ver sección siguiente)
$agents->delete($ana['agentId']);

Acceso de bajo nivel

Si necesitas una ruta que aún no tiene método, usa los verbos del cliente:

$client->get('/campaigns', ['status' => 'RUNNING']);
$client->post('/campaigns/cmp-1/launch', [], ['startAt' => '…'], ['idempotencyKey' => 'k']);
$client->put(…); $client->patch(…); $client->delete(…);

Paginación

Las colecciones grandes (campaigns, calls, dnc, lead-lists/{id}/leads, credits/ledger) devuelven {items, nextCursor}. El SDK las envuelve en un Callstorm\Paginator que sigue nextCursor de forma perezosa:

$calls = $client->calls()->list(campaignId: 'cmp-1', limit: 100);

foreach ($calls as $call) { … }                 // todos los elementos, página a página bajo demanda
foreach ($calls->pages() as $page) { … }        // ['items' => […], 'nextCursor' => '…'|null]
$first = $calls->firstPage();                   // una sola petición
$some  = $calls->all(250);                      // carga páginas hasta reunir 250 elementos

Cada página es una petición; la siguiente solo se pide cuando el iterador la necesita. Para procesar colecciones muy grandes usa foreach directamente en lugar de all().

Idempotencia

Cualquier POST/PUT/DELETE admite Idempotency-Key (≤ 128 caracteres). La primera respuesta se guarda 24 h por clave; una repetición con la misma clave devuelve la misma respuesta con Idempotent-Replayed: true sin volver a ejecutar la operación. Los errores 5xx no se guardan, así que sí pueden reintentarse.

$campaign = $client->campaigns()->create($input, 'crm-batch-2026-09-27');
if ($client->wasReplayed()) {
    // la campaña ya existía: la respuesta viene del almacén de idempotencia
}

Usa claves derivadas de tu propio identificador de negocio ('launch-' . $campaignId, 'export-' . $batchId…) para que un reintento de tu sistema nunca duplique campañas, lanzamientos ni exportaciones.

Límite de tasa

Cada clave tiene un límite por minuto UTC (600 por defecto, hasta 6 000 a petición de soporte). Toda respuesta trae X-RateLimit-Limit, X-RateLimit-Remaining y X-RateLimit-Reset:

$client->get('/me');
$rate = $client->rateLimit();   // ['limit' => 600, 'remaining' => 599, 'resetAt' => '2026-10-03T12:01:00Z']

Al superar el límite la API responde 429 RATE_LIMITED con details.resetAt. El SDK espera automáticamente hasta resetAt (máximo 60 s) y reintenta, hasta maxRetries veces. Si se agotan los reintentos lanza RateLimitedException, cuyo resetAt() y retryAfterSeconds() te permiten esperar por tu cuenta.

Errores

Toda respuesta de error tiene la forma {code, message, details} y se convierte en una excepción de Callstorm\Exception\. Todas descienden de CallstormException; las de la API, de ApiException, que expone status(), apiCode(), getMessage(), details(), requestId() e idempotentReplayed().

HTTP code Excepción Extras
400 VALIDATION_ERROR ValidationException issues() → [{path, message}, …]
401 UNAUTHORIZED UnauthorizedException clave ausente, revocada o caducada
402 INSUFFICIENT_CREDITS InsufficientCreditsException
403 FORBIDDEN ForbiddenException requiredScope()
404 NOT_FOUND NotFoundException
409 INVALID_TRANSITION, NOT_DRAFT, IN_USE ConflictException distingue con apiCode()
413 PAYLOAD_TOO_LARGE PayloadTooLargeException
429 RATE_LIMITED RateLimitedException resetAt(), retryAfterSeconds()
otros 4xx/5xx — ApiException
sin respuesta (red, DNS, timeout) — TransportException

Además: TimeoutException y ProcessingFailedException (helpers waitUntil*, ambas con resource()), InvalidSignatureException (webhooks).

use Callstorm\Exception\{ApiException, ConflictException, RateLimitedException, TransportException, ValidationException};

try {
    $client->campaigns()->launch($id);
} catch (ValidationException $e) {
    foreach ($e->issues() as $issue) { echo "{$issue['path']}: {$issue['message']}\n"; }
} catch (ConflictException $e) {
    if ($e->apiCode() === 'INVALID_TRANSITION') { /* ya estaba lanzada */ }
} catch (RateLimitedException $e) {
    sleep((int) ceil($e->retryAfterSeconds()));
} catch (ApiException $e) {
    error_log(sprintf('%d %s %s (request %s)', $e->status(), $e->apiCode(), $e->getMessage(), $e->requestId()));
} catch (TransportException $e) {
    // sin conectividad
}

Subida de listas de contactos

El flujo de la API es: POST /files/presign → PUT del archivo a uploadUrl (almacenamiento S3) → POST /lead-lists con sourceFileKey → esperar status = READY. El SDK lo encapsula:

$lists = $client->leadLists();

// Paso a paso
$key  = $lists->uploadFile('deudores.csv');                       // infiere text/csv por la extensión
$key  = $lists->uploadFile('deudores.xlsx', 'lote.xlsx');         // nombre remoto distinto
$key  = $lists->uploadFile('/tmp/x', 'lote.csv', 'text/csv');     // tipo explícito
$list = $lists->create([
    'name' => 'Deudores', 'country' => 'CL', 'sourceFileKey' => $key,
    'columnMapping' => ['telefono' => 'phone', 'nombre' => 'nombre', 'deuda' => 'monto'],
    'consent' => true,
]);

// O todo en uno
$list = $lists->createFromFile('deudores.csv', ['name' => 'Deudores', 'country' => 'CL', 'columnMapping' => […]]);

// El procesamiento es asíncrono
try {
    $list = $lists->waitUntilReady($list['leadListId'], 300, 3);
    echo "{$list['validRows']} válidos, {$list['rejectedRows']} rechazados";
} catch (\Callstorm\Exception\ProcessingFailedException $e) {
    // status ERROR: revisa $e->resource()['rejectedFileUrl'] si existe
} catch (\Callstorm\Exception\TimeoutException $e) {
    // sigue procesando; vuelve a consultar más tarde o suscríbete a lead_list.processed
}

columnMapping asigna cada columna del archivo a phone o al nombre de una variable disponible en la campaña ({{nombre}}, {{monto}}…). consent: true es obligatorio y queda registrado con la clave y la hora.

Webhooks

Registra la URL (HTTPS) y los eventos con webhooks()->create(); la respuesta incluye secret una única vez. Cada entrega es un POST con cabeceras X-Voxcore-Event, X-Voxcore-Delivery y X-Voxcore-Signature: sha256=<hex HMAC-SHA256(secret, cuerpo crudo)>, y un cuerpo {id, type, time, data}.

Eventos: call.completed, campaign.status_changed (y sus alias campaign.started, campaign.completed, campaign.paused), lead_list.processed, balance.low (alias credits.low), payment.succeeded. Los esquemas de data están en docs/PUBLIC_API.md y en x-webhooks del OpenAPI.

Verificación de firma

use Callstorm\Webhook\Signature;

$valid = Signature::verify($rawBody, $_SERVER['HTTP_X_VOXCORE_SIGNATURE'] ?? '', $secret);

La comparación es en tiempo constante y acepta la firma con o sin el prefijo sha256=. Calcula siempre sobre el cuerpo crudo (php://input), nunca sobre el JSON re-serializado.

Ejemplo en PHP plano

use Callstorm\Exception\InvalidSignatureException;
use Callstorm\Webhook\Event;

try {
    $event = Event::fromGlobals($secret);   // lee php://input y las cabeceras, verifica la firma
} catch (InvalidSignatureException $e) {
    http_response_code(401);
    exit;
}

http_response_code(200); // responde en < 8 s; procesa después

if ($event->isCallCompleted()) {
    $call = $event->data;   // callId, campaignId, phone, disposition, resultCode, durationSec, variables…
} elseif ($event->isCampaignStatusChanged()) {
    // $event->data['from'], ['to'], ['stats']
} elseif ($event->isLeadListProcessed()) {
    // $event->data['leadListId'], ['validRows']
}

Event expone id, type, time, data (y payload completo) más isCallCompleted(), isCampaignStatusChanged(), isLeadListProcessed(), isBalanceLow(), isPaymentSucceeded(). Las entregas se reintentan hasta 3 veces: usa id para descartar duplicados. Ejemplo completo en examples/webhook-receiver.php.

Laravel / Symfony

// Laravel (routes/api.php => sin CSRF). Las cabeceras PSR-7 (string[]) se aceptan tal cual.
public function __invoke(Request $request): JsonResponse
{
    try {
        $event = Event::fromRequest($request->getContent(), $request->headers->all(), config('services.voxcore.webhook_secret'));
    } catch (InvalidSignatureException) {
        return response()->json(['error' => 'invalid signature'], 401);
    }
    ProcessCallstormEvent::dispatch($event->toArray());
    return response()->json(['status' => 'queued'], 202);
}
// Symfony
$event = Event::fromRequest($request->getContent(), $request->headers->all(), $secret);

Controlador completo en examples/laravel-webhook-controller.php.

Consola de agente en tu CRM (widget)

Tus agentes pueden trabajar sin salir de tu CRM: la consola de agente de Callstorm (conectarse, pausarse con motivo, elegir la campaña que atienden, recibir y hacer llamadas con el softphone del navegador, marcar en modo manual o vista previa y calificar cada llamada) se incrusta en tu página y tu backend PHP solo tiene que pedir un token de sesión por agente.

  1. Alta del agente (una vez): en Configuración → Agentes de Callstorm o con $client->agents()->create([...]). Guarda el agentId (ag-…) junto al usuario de tu CRM.
  2. Backend, al abrir la página del agente: pide el token con la clave de API (scope settings:telephony).
$session = $client->agents()->sessionToken($agentId, ttlSec: 8 * 3600, idempotencyKey: 'crm-session-' . session_id());
// ['token' => 'vxa_live_…', 'agentId' => 'ag-…', 'expiresAt' => '…', 'embedUrl' => 'https://app.callstorm.ai/embed/agent']
$loaderUrl = \Callstorm\Resource\Agents::loaderUrl($session); // https://app.callstorm.ai/embed/voxcore-agent.js
  1. Página del CRM: carga el loader y monta el widget con el token (nunca en la URL ni en logs).
<div id="voxcore-agent"></div>
<script src="https://app.callstorm.ai/embed/voxcore-agent.js"></script>
<script>
  var widget = CallstormAgent.mount({ container: '#voxcore-agent', token: '<?= $session['token'] ?>', lang: 'es', height: '760px' });
  widget.on('call.ringing',   function (e) { abrirFicha(e.call); });   // teléfono, campaña y lead con sus variables
  widget.on('call.connected', function (e) { abrirFicha(e.call); });   // en colas de conferencia no hay timbrado previo
  widget.on('call.updated',   function (e) { abrirFicha(e.call); });   // el lead llegó un instante después del evento
  widget.on('call.ended',     function (e) { registrar(e.call); });    // transactionId, talkSec, disposition
  widget.on('status',         function (e) { presencia(e.agent); });   // AVAILABLE / PAUSED (+ motivo) / BUSY / …
  widget.on('session.expired', function () { location.reload(); });    // el backend emite otro token
</script>
  • Eventos: ready, status, call.ringing, call.connected, call.updated, call.ended, session.expired (o *).
  • Acciones desde la página: widget.setStatus('AVAILABLE' | 'PAUSED' | 'OFFLINE', motivo?), widget.join(campaignId | null), widget.answer(), widget.hangup(), widget.setToken(token) (renovación), widget.destroy().
  • El token actúa solo como ese agente y solo en las rutas de la consola; dura 8 h por defecto (5 min – 12 h) y cada emisión queda auditada. Sin loader, también sirve un iframe a mano: <iframe src="{embedUrl}#token={token}&lang=es" allow="microphone; autoplay"> (el fragmento no llega al servidor y la página lo retira al cargar).
  • El audio es WebRTC entre el navegador del agente y el SBC de Callstorm: tu servidor PHP no participa en la llamada. El iframe necesita el permiso de micrófono (allow="microphone; autoplay", que el loader ya pone).

Ejemplo completo (PHP plano y Laravel): examples/crm-agent-widget.php.

Buenas prácticas

Reintentos. El cliente reintenta hasta maxRetries (2 por defecto):

  • 429: espera hasta resetAt (tope 60 s) y reintenta, con cualquier verbo — la petición fue rechazada antes de ejecutarse, por lo que repetirla es seguro.
  • Errores de red y 502/503/504: backoff exponencial (0,5 s, 1 s, 2 s… tope 8 s) solo para GET o para escrituras que llevan Idempotency-Key. Una escritura sin clave nunca se reintenta automáticamente: pásale una clave siempre que la operación no deba duplicarse.
  • Pon maxRetries => 0 si prefieres gestionar los reintentos en tu propia cola.

Claves. Guarda el token en un gestor de secretos o variable de entorno, nunca en el código ni en el repositorio. Crea una clave por integración con los scopes mínimos, con caducidad, y rota: crea la nueva, despliega, revoca la antigua. Si se filtra, revócala en la web (efecto inmediato) y crea otra; el token no se puede recuperar.

Ambientes. Usa claves vx_test_… y baseUrl => 'https://api-dev.callstorm.ai/v1/ext' en desarrollo, y vx_live_… con la URL por defecto en producción. Separa también los webhooks por ambiente (secretos distintos).

Webhooks. Responde 2xx en menos de 8 s y procesa en segundo plano; descarta entregas repetidas por id; exige HTTPS; verifica siempre la firma antes de leer el cuerpo.

Procesos largos. Para listas grandes o exportaciones, prefiere los webhooks lead_list.processed a sondear con waitUntil* desde una petición web; si sondeas, hazlo desde un worker con un timeoutSec razonable.

Logs. Registra requestId() de las excepciones para que soporte pueda localizar la petición.

Versionado y compatibilidad

  • Versionado semántico. Mientras la versión sea 0.x, los cambios incompatibles se anuncian en el CHANGELOG en versiones menores.
  • Compatible con PHP 8.1, 8.2 y 8.3 (CI en 8.1 y 8.3). Sin dependencias de terceros en producción.
  • Las respuestas son arrays: los campos nuevos que añada la API aparecen automáticamente sin actualizar el SDK.
  • Especificación de referencia: https://api.callstorm.ai/v1/public/openapi.json (misma versión que sirve la API).

Desarrollo del SDK

cd sdk/php
composer install
composer lint   # php -l en todos los archivos
composer test   # PHPUnit 10

Licencia MIT — IDTecnology / Callstorm.