Referencia

Documentación

Todo lo que necesitas para integrarte con Pegasus: la API REST, los webhooks firmados y el servidor MCP.

Empezar

De una llave nueva a tu primera respuesta.

La API de Pegasus es REST sobre HTTPS, con JSON en los dos sentidos. La misma llave abre la API REST y el servidor MCP; no hay dos credenciales que administrar.

  1. Entra a la consola y abre Desarrolladores → Llaves.
  2. Crea una llave. Elige el entorno test mientras desarrollas: una llave test nunca hace sonar un teléfono.
  3. Marca solo los permisos que tu integración necesita. No hay comodines y ninguno implica a otro.
  4. Copia la llave en ese momento. Se muestra una sola vez y no hay forma de volver a verla.

Las dos direcciones base. Los ejemplos de esta página usan $PEGASUS_API para la primera, $PEGASUS_MCP para la segunda y $PEGASUS_KEY para tu llave, así que puedes exportarlas y copiar los comandos tal cual.

Tu dirección base aparece en la consola, en Desarrolladores → API.

Tu primera petición. Devuelve la primera página de leads, más reciente primero.
bash
curl -s "$PEGASUS_API/v1/leads?limit=2" \
  -H "Authorization: Bearer $PEGASUS_KEY"
La respuesta. Toda lista responde con la misma envoltura: data y next_cursor.
json
{
  "data": [
    {
      "id": "0f8b4d2e-1c3a-4f5b-9d7e-2a6c8b0e4f11",
      "external_id": "crm-88213",
      "first_name": "Rosa",
      "last_name": "Medina",
      "phone_e164": "+13055551234",
      "status": "queued",
      "dial_hold": false,
      "created_at": "2026-09-10T18:22:04.113Z"
    }
  ],
  "next_cursor": "MjAyNi0wOS0xMFQxODoyMjowNC4xMTNafDBmOGI0ZDJl"
}

Autenticación y permisos

Llaves, entornos y qué abre cada permiso.

Toda petición lleva la llave en la cabecera Authorization. No hay llaves por query string, ni cookies, ni sesiones: una petición sin cabecera válida recibe 401 y nada más.

La cabecera, y la que te devolvemos en cada respuesta.
http
Authorization: Bearer pgs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

x-pegasus-request-id: 9c1f6a2e-7b40-4d18-a3e5-51d9f0c7b2aa

Guarda el x-pegasus-request-id de cualquier respuesta que te sorprenda. Es el mismo identificador que aparece en el registro de la consola y en el campo request_id de todo error, y es lo primero que te vamos a pedir.

EntornoPrefijo
livepgs_live_
testpgs_test_

Una llave live es la de producción: con dial:write, los leads que crea entran a la cola y suena un teléfono real. Una llave test escribe en los mismos datos pero no puede causar contacto: todo lead que crea queda retenido, y no puede agendar citas, tenga los permisos que tenga.

Los permisos son un conjunto cerrado. No hay comodines y ninguno implica a otro: leads:write no te deja leer leads. Pedir un permiso que no existe falla al crear la llave, no en la primera petición.

PermisoQué hace
leads:readLeer leads y su estado
leads:writeCrear y actualizar leads
calls:readLeer llamadas y sus resultados
conversions:readLeer conversiones (sin los datos restringidos)
brain:readLeer el cerebro del agente (guion, conocimiento, criterios)
brain:writeProponer cambios al agente y borradores de conocimiento (una persona los publica)
appointments:readLeer citas
appointments:writeCrear y mover citas
events:readLeer el registro de eventos
webhooks:manageAdministrar endpoints de webhooksin ruta todavía
dial:writeEncolar llamadas (hace sonar un teléfono real)modificadorhace sonar un teléfono
EntornoPermiso dial:writeEl lead queda
livesíen la cola — se llama
livenoretenido — nadie lo llama
testsíretenido — nadie lo llama
testnoretenido — nadie lo llama

Separar los dos permisos no es decoración: leads:write crea una fila, dial:write hace sonar un teléfono en casa de alguien, y la responsabilidad legal bajo TCPA se aplica al segundo. Una integración que solo alimenta un CRM no puede llegar ahí por accidente.

Endpoints

Las rutas, sus campos y sus filtros.

Toda ruta vive bajo /v1. Cada una exige exactamente un permiso, resuelto antes de que la petición llegue a su manejador: una ruta sin permiso no compila, así que no existe una ruta abierta por descuido.

MétodoRutaPermisoQué haceHerramienta MCP
GET/v1/leadsleads:readLa lista de leads, del más reciente al más antiguo, con filtros y paginación por cursor.No incluye ensayos.list_leads
GET/v1/leads/:idleads:readUn lead por su id. Devuelve las mismas columnas que la lista.No incluye ensayos.get_lead
POST/v1/leadsleads:writeCrea uno o varios leads. Acepta un objeto suelto o { leads: [...] }, y responde con un veredicto por fila.create_leads
GET/v1/callscalls:readLos intentos de llamada y cómo terminó cada uno.No incluye ensayos.list_calls
GET/v1/calls/:idcalls:readUn intento de llamada por su id.No incluye ensayos.get_call
GET/v1/conversionsconversions:readLas conversiones: qué objetivo se persiguió, si se cumplió y los datos abiertos que ese objetivo declara.No incluye ensayos.list_conversions
POST/v1/appointmentsappointments:writeAgenda una cita para un lead. Una llave de entorno test no puede usarla.create_appointment
GET/v1/appointmentsappointments:readLas citas agendadas. `source` dice quién la agendó: el agente porque no había nadie libre, el agente porque la transferencia no conectó, una persona, o un calendario conectado.No incluye ensayos.list_appointments
GET/v1/eventsevents:readEl registro de eventos de la organización. Cada `payload` es el mismo cuerpo que recibe un webhook, armado campo por campo: no lleva transcripciones, ni grabaciones, ni respuestas de salud, ni la evidencia del consentimiento.No incluye ensayos.list_events
GET/v1/agentsbrain:readLos agentes de IA de la organización. `config` son los criterios contra los que califica, en los términos del objetivo que tiene fijado. Es lo que le da sentido al `failed_criterion` de una conversión.list_agents
GET/v1/scriptsbrain:readLas variantes de guion: la apertura, el descubrimiento, el cierre y las respuestas a objeciones. Solo una variante `published` puede llegar a un teléfono, y solo como brazo de un experimento en curso.list_scripts
GET/v1/knowledgebrain:readLos datos que el agente puede afirmar, en pares de pregunta y respuesta. Solo los `published` llegan a una llamada. Publicar es un acto de una persona en la consola; `brain:write` puede guardar un borrador (POST /v1/knowledge) y nada más.list_knowledge
GET/v1/call-scriptsbrain:readQué guion leyó cada intento de llamada. Únelo a /v1/calls por `call_attempt_id` y a /v1/scripts por `script_variant_id`. Ojo con `script_resolution`: una fila `fallback` no pertenece a ningún brazo y hay que descartarla antes de calcular una tasa por variante. Lleva la marca del prompt, nunca el prompt.No incluye ensayos.list_call_scripts
GET/v1/stage-agentsbrain:readLos agentes por etapas de la organización: quién es cada uno al hablar (`persona`) y para qué sirve (`purpose`). Solo uno `active` trabaja leads. Sus etapas están en /v1/stages.list_stage_agents
GET/v1/stagesbrain:readLas etapas de un agente; filtra por `ai_agent_id`. `script` es lo que el agente dice en esa parte y `required_fields` es la puerta: lo que tiene que haberse recogido para salir de ella. La puerta se lee y no se propone.list_stages
GET/v1/agent-proposalsbrain:writeLos cambios propuestos a agentes por etapas a través de esta API, con su `status`: `proposed` espera a una persona, `applied` está en vivo, `rejected` se descartó. `before` es el texto contra el que se escribió.list_agent_proposals
POST/v1/agent-proposalsbrain:writePropone texto nuevo para un agente por etapas. No cambia nada: deja una propuesta que un administrador de la agencia aplica o descarta en la consola. Con `stage_id`, `changes` admite `script`, `entry_hint` y `name`; sin él, `name`, `purpose` y `persona`. Las puertas, el orden, las herramientas y encender o apagar el agente no se proponen.propose_agent_change
POST/v1/knowledgebrain:writeCrea un dato como borrador, o edita uno que sigue siendo borrador (con `id`). Un borrador nunca se dice en una llamada: lo publica una persona en la consola. Uno ya publicado no se edita desde aquí; se guarda un borrador nuevo.save_knowledge_draft

Las listas responden { data, next_cursor }. Las lecturas por id responden { data }. Las escrituras responden su propio resumen, descrito más abajo.

Leads. Las columnas que devuelve y los parámetros con los que puedes filtrar:

Campos de la respuesta

idexternal_idfirst_namelast_namephone_e164emailstatepostal_codetimezonetimezone_confidencestatusdisqualify_reasonattempt_countlast_attempt_atdial_holdoperator_idlead_source_idcreated_by_api_keycreated_atupdated_at

Parámetros de consulta

ParámetroColumnaTipoOperador
created_aftercreated_attimestampgte
created_beforecreated_attimestamplte
dial_holddial_holdbooleaneq
external_idexternal_idtexteq
operator_idoperator_iduuideq
phonephone_e164texteq
statestatetexteq
statusstatustexteq

experiment_arm no está y no es un olvido: el brazo lo asigna la plataforma, y nada de un grupo de control es asunto de quien lo consume.

Llamadas. Cada intento, con su resultado y su duración:

Campos de la respuesta

idlead_idoperator_idattempt_numberprovider_keydial_modeoutcomeduration_secondsreview_requiredstarted_atended_atcreated_at

Parámetros de consulta

ParámetroColumnaTipoOperador
created_aftercreated_attimestampgte
created_beforecreated_attimestamplte
lead_idlead_iduuideq
operator_idoperator_iduuideq
outcomeoutcometexteq

Conversiones. Un objetivo declara qué variables recoge, y la conversión las guarda en facts, así que la forma de ese objeto depende de objective_key y objective_version —los dos vienen en la misma fila para que puedas leerlo sin adivinar:

Campos de la respuesta

idlead_idoperator_idagent_idcall_attempt_idobjective_keyobjective_versionconversion_schema_versionfactspassedfailed_criteriontranscript_completeconverted_atcreated_at

Parámetros de consulta

ParámetroColumnaTipoOperador
created_aftercreated_attimestampgte
created_beforecreated_attimestamplte
lead_idlead_iduuideq
objective_keyobjective_keytexteq
passedpassedbooleaneq

Crear leads. Acepta un objeto suelto o { leads: [...] }, hasta 500 por petición:

Campos de cada lead. Todos opcionales salvo que tu organización exija lo contrario; un lead sin teléfono se acepta pero no se puede llamar.

CampoTipoObligatorioNota
external_idstringnoTu identificador. Es lo que te devolvemos en cada veredicto para que sepas qué fila es cuál.
first_namestringnoNombre.
last_namestringnoApellido.
phonestringnoSe normaliza a E.164. Sin teléfono válido el lead existe pero nunca se marca.
emailstringnoCorreo.
statestringnoEstado de dos letras. Decide la zona horaria y con ella las horas en que se puede llamar.
postal_codestringnoCódigo postal.
holdbooleannoRetén este lead aunque tu llave pueda marcar. Retener es la dirección segura: si tú o la llave insisten en retener, se retiene.
lead_source_iduuidnoLa fuente que declara a qué consintió esta persona.
operator_iduuidnoA qué operador se asigna. Si lo omites y hay uno solo, se usa ese.
consentobjectnoLa evidencia de consentimiento de esta persona. Sin ella, el lead puede rechazarse según la configuración de tu organización.
La respuesta de una escritura por lotes. Una fila mala no tumba las demás: el lote responde 200 con el veredicto de cada una.
json
{
  "created": 2,
  "duplicate": 1,
  "rejected": 0,
  "dialing": "queued",
  "held_by_request": 1,
  "results": [
    { "status": "created",   "external_id": "crm-88213", "lead_id": "0f8b4d2e-…" },
    { "status": "created",   "external_id": "crm-88214", "lead_id": "7a2c1b90-…" },
    { "status": "duplicate", "external_id": "crm-88213",
      "reason": "a live lead already has this number" }
  ]
}

Cada fila trae status created, duplicate o rejected. Un duplicate explica cuál de las dos reglas lo atrapó —el external_id ya recibido, o un lead vivo con ese mismo número— y un rejected trae el motivo tal cual lo dio la base de datos. dialing es el veredicto de la llave para todo el lote: queued si estos leads entran a la cola, held si no. held_by_request cuenta los que tú retuviste con hold aunque la llave sí podía marcar. Lee dialing en vez de suponerlo a partir de los permisos.

Agendar una cita. Dos campos obligatorios:

Cuerpo de POST /v1/appointments.

CampoTipoObligatorioNota
lead_iduuidsíEl lead que va a la cita. Un lead de otra organización responde 404, nunca 403.
scheduled_fortimestamptzsíCuándo. Una fecha que no se puede leer responde invalid_parameter.
operator_iduuidnoCon qué operador. Si lo omites y hay uno solo, se usa ese; si hay varios, es obligatorio.

Errores

Cómo se ve una negativa y qué hacer con cada una.

Todo error responde con la misma forma. Ramifica sobre error, que es un código estable, nunca sobre message, que es una frase para humanos y puede cambiar.

El cuerpo de un error. detail aparece solo cuando hay algo concreto que señalar, como el parámetro que no se entendió.
json
{
  "error": "invalid_parameter",
  "message": "A query parameter was not understood.",
  "request_id": "9c1f6a2e-7b40-4d18-a3e5-51d9f0c7b2aa",
  "detail": "created_after"
}
CódigoEstadoMensaje
unauthorized401Provide a valid key as `Authorization: Bearer pgs_live_...`.
forbidden_scope403This key does not hold the scope this endpoint requires.
test_key_cannot_dial403A test-environment key cannot cause a call. The lead was not created; retry with a live key or without `dial`.
test_key_cannot_contact403A test-environment key cannot schedule an appointment, because an appointment sends text messages to a real telephone.
not_found404No such route, or no such row for this key.
method_not_allowed405That method is not allowed on this route.
invalid_body400The request body could not be read.
invalid_parameter400A query parameter was not understood.
invalid_cursor400The cursor is not one this API issued. Start the page again without it.
too_many_rows400The batch is larger than this endpoint accepts.
rate_limited429Too many requests from this key. Slow down and retry.
principal_unavailable503This organization's API identity is unavailable. This is our fault and it is retryable.
internal500Something went wrong on our side.

429 está reservado y todavía no lo emitimos. Manéjalo ahora de todos modos: cuando exista un limitador, tu integración no tendrá que cambiar para enterarse.

503 principal_unavailable es nuestro y es reintentable. 500 internal también es nuestro: guarda el request_id y escríbenos.

Paginación

Cursores de rango, y por qué no hay offset.

Toda lista ordena por (created_at desc, id desc) y pagina por cursor. No hay offset ni page, y es a propósito: mientras recorres las páginas siguen entrando leads, y un offset habría hecho que cada lead nuevo empujara una fila de la página 2 a la 3 sin que la vieras nunca.

El id desempata, porque dos leads importados en el mismo milisegundo son comunes y un cursor que no puede distinguirlos repite filas o las salta.

ParámetroQué hace
limitCuántas filas quieres. Por omisión 25, máximo 100.
cursorEl next_cursor de la respuesta anterior, tal cual.
Recorrer todas las páginas. next_cursor en null es el final; no hay otra señal que buscar.
javascript
async function* allLeads(base, key, params = {}) {
  let cursor = null;
  do {
    const qs = new URLSearchParams({ ...params, limit: "100" });
    if (cursor) qs.set("cursor", cursor);

    const res = await fetch(`${base}/v1/leads?${qs}`, {
      headers: { Authorization: `Bearer ${key}` },
    });
    if (!res.ok) {
      const body = await res.json();
      throw new Error(`${body.error} (request ${body.request_id})`);
    }

    const page = await res.json();
    yield* page.data;
    cursor = page.next_cursor;
  } while (cursor);
}

next_cursor viene en null cuando la página devolvió menos filas que el limit que pediste, que es como sabes que llegaste al final. Aun así, detente por next_cursor y no contando filas: es una sola condición y no cambia si algún día paginamos distinto.

Webhooks

Eventos firmados, reintentos y cómo verificar la firma.

Registra un endpoint https en la consola, marca los eventos que te interesan y te mandamos un POST firmado cada vez que ocurra uno. El secreto de firma se muestra una sola vez, igual que la llave.

El conjunto de eventos es cerrado. Un tipo de evento es una promesa: en cuanto el CRM de alguien enciende qualification.passed, renombrarlo le rompe la nómina a otra persona. No hay eventos personalizados.

EventoDescripción
lead.createdUn lead entró por cualquiera de las cinco vías.
call.completedUn intento de llamada terminó, con su resultado.
call.transferredUna transferencia en caliente conectó con el agente.
qualification.passedEl lead cumplió los cinco criterios.
qualification.failedEl lead no calificó, con el criterio que falló.
appointment.scheduledSe agendó una cita.
compliance.opt_outUn número entró a la lista de no llamar.
El sobre. Es siempre el mismo; data cambia según el tipo. id identifica el EVENTO y delivery_id este intento de entrega en particular.
json
{
  "id": "3f9a71c2-5d84-4e02-9b6f-8c1d20a7e534",
  "type": "qualification.passed",
  "organization_id": "b41d7e08-2c6f-4a91-8de3-05f7c1a9b662",
  "delivery_id": "c7e0a913-4f52-4b88-9a1d-6e3b027fd845",
  "data": {
    "conversion_id": "18d5c4a7-9e31-4026-b7fa-3c08e5192d74",
    "lead_id": "0f8b4d2e-1c3a-4f5b-9d7e-2a6c8b0e4f11",
    "operator_id": "5a9e2c71-08b4-4d63-9f27-1ac6d0b85e39",
    "call_attempt_id": "9b3f6d20-7c18-4e55-a0d9-2f47b6c1e803",
    "passed": true,
    "failed_criterion": null,
    "schema_version": 1,
    "spoke_with_titular": true,
    "interest_confirmed": true,
    "converted_at": "2026-09-10T18:24:11.412Z"
  }
}

Fíjate en lo que NO trae ese payload: ni nombre ni teléfono. Los datos de contacto viajan en lead.created, que es el evento que los tiene; los demás se refieren al lead por lead_id. Si tu CRM necesita el teléfono al calificar, guárdalo cuando llegue lead.created o pídelo con GET /v1/leads/:id.

lead.created, que sí trae los datos de contacto: ya son tuyos, y un evento de lead sin teléfono no puede hacer el único trabajo que tiene una integración de CRM.
json
{
  "id": "8c40b17e-93d2-4f6a-b015-7e2ac9d3f481",
  "type": "lead.created",
  "organization_id": "b41d7e08-2c6f-4a91-8de3-05f7c1a9b662",
  "delivery_id": "e19f7b53-0a64-4c27-8db6-45f3c80e2917",
  "data": {
    "lead_id": "0f8b4d2e-1c3a-4f5b-9d7e-2a6c8b0e4f11",
    "external_id": "crm-88213",
    "first_name": "Rosa",
    "last_name": "Medina",
    "phone_e164": "+13055551234",
    "email": "rosa.medina@example.com",
    "state": "FL",
    "postal_code": "33125",
    "timezone": "America/New_York",
    "status": "queued",
    "dial_hold": false,
    "lead_source_id": "2d7c9410-63ab-4e15-8f02-9b41c7d05a68",
    "operator_id": "5a9e2c71-08b4-4d63-9f27-1ac6d0b85e39",
    "created_at": "2026-09-10T18:22:04.113Z"
  }
}

Nunca se envía

a1chealthtranscriptrecordingconsent_evidence_urlcost_usdssnitin

Cada POST lleva estas cabeceras:

CabeceraQué trae
x-pegasus-signaturet=<unix>,v1=<hmac-sha256 en hex>. Verifícala siempre.
x-pegasus-event-idEl id del evento. Es tu clave de deduplicación.
x-pegasus-event-typeEl tipo, para enrutar sin abrir el cuerpo.
x-pegasus-delivery-idEste envío en particular. Cambia en cada reintento.
x-pegasus-attemptNúmero de intento, empezando en 1.
user-agentPegasus-Webhooks/1

Firmamos la cadena `${t}.${cuerpo}` con HMAC-SHA256 usando el secreto de tu endpoint, en hexadecimal. Verifica sobre el cuerpo crudo, byte por byte: si tu framework ya parseó el JSON y lo volviste a serializar, la firma no va a coincidir aunque el contenido sea el mismo.

Verificación en Node, sin dependencias. Compara en tiempo constante y rechaza lo viejo.
javascript
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

export function verifyPegasusSignature(secret, rawBody, header, now = Date.now()) {
  if (!header) return false;

  const parts = Object.fromEntries(
    header.split(",").map((kv) => {
      const i = kv.indexOf("=");
      return i < 0 ? [kv.trim(), ""] : [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
    }),
  );

  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!Number.isFinite(t) || !/^[0-9a-f]{64}$/.test(v1 ?? "")) return false;

  // Reject a captured request replayed next week.
  if (Math.abs(Math.floor(now / 1000) - t) > TOLERANCE_SECONDS) return false;

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

  // Equal lengths guaranteed by the hex check above.
  return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(v1, "hex"));
}
En Express, quédate con el cuerpo crudo antes de que nadie lo parsee.
javascript
app.post(
  "/pegasus",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const raw = req.body.toString("utf8");
    if (!verifyPegasusSignature(SECRET, raw, req.get("x-pegasus-signature"))) {
      return res.sendStatus(401);
    }

    // Answer fast, then work. We time out and retry a slow endpoint.
    res.sendStatus(200);
    enqueue(req.get("x-pegasus-event-id"), JSON.parse(raw));
  },
);

Un evento se intenta 6 veces en total: el primer envío y 5 reintentos, al minuto, a los 5 minutos, a la media hora, a las 2 horas y a las 6 horas. Son unas ocho horas, suficiente para un despliegue, la renovación de un certificado y una noche de sueño, sin dejar una fila colgada una semana. Después de 20 fallos seguidos apagamos el endpoint y la consola lo dice: a quien movió su servidor hay que avisarle, no reintentarle para siempre.

Cualquier respuesta 2xx cuenta como entregada. Todo lo demás, incluido un tiempo de espera agotado, cuenta como fallo y entra a la escalera de reintentos.

MCP

Conectar un modelo a la misma API, con la misma llave.

Pegasus también habla MCP sobre HTTP (streamable). Es la misma API detrás: las mismas rutas, los mismos permisos, la misma llave en la misma cabecera. No hay una credencial aparte que administrar.

En Claude Code, una línea. En otros clientes, la misma dirección y la misma cabecera.
bash
claude mcp add --transport http pegasus \
  "$PEGASUS_MCP" \
  --header "Authorization: Bearer $PEGASUS_KEY"
Herramienta MCPPermisoRuta
list_leadsleads:readGET /v1/leads
get_leadleads:readGET /v1/leads/:id
create_leadsleads:writePOST /v1/leads
list_callscalls:readGET /v1/calls
get_callcalls:readGET /v1/calls/:id
list_conversionsconversions:readGET /v1/conversions
create_appointmentappointments:writePOST /v1/appointments
list_appointmentsappointments:readGET /v1/appointments
list_eventsevents:readGET /v1/events
list_agentsbrain:readGET /v1/agents
list_scriptsbrain:readGET /v1/scripts
list_knowledgebrain:readGET /v1/knowledge
list_call_scriptsbrain:readGET /v1/call-scripts
list_stage_agentsbrain:readGET /v1/stage-agents
list_stagesbrain:readGET /v1/stages
list_agent_proposalsbrain:writeGET /v1/agent-proposals
propose_agent_changebrain:writePOST /v1/agent-proposals
save_knowledge_draftbrain:writePOST /v1/knowledge

El servidor no tiene estado y no manda mensajes por su cuenta: GET responde 405 porque no hay stream SSE que abrir, y no hay sampling ni suscripciones. Una negativa de la API llega como resultado de herramienta marcado isError, no como un error de transporte, para que el modelo pueda leerla y corregir en vez de quedarse sin saber qué pasó.

¿Algo aquí no coincide con lo que ves? Guarda el x-pegasus-request-id y escríbenos.