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.
- Entra a la consola y abre Desarrolladores → Llaves.
- Crea una llave. Elige el entorno test mientras desarrollas: una llave test nunca hace sonar un teléfono.
- Marca solo los permisos que tu integración necesita. No hay comodines y ninguno implica a otro.
- 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.
curl -s "$PEGASUS_API/v1/leads?limit=2" \
-H "Authorization: Bearer $PEGASUS_KEY"{
"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.
Authorization: Bearer pgs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
x-pegasus-request-id: 9c1f6a2e-7b40-4d18-a3e5-51d9f0c7b2aaGuarda 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.
| Entorno | Prefijo |
|---|---|
| live | pgs_live_ |
| test | pgs_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.
| Permiso | Qué hace | |
|---|---|---|
| leads:read | Leer leads y su estado | |
| leads:write | Crear y actualizar leads | |
| calls:read | Leer llamadas y sus resultados | |
| conversions:read | Leer conversiones (sin los datos restringidos) | |
| brain:read | Leer el cerebro del agente (guion, conocimiento, criterios) | |
| brain:write | Proponer cambios al agente y borradores de conocimiento (una persona los publica) | |
| appointments:read | Leer citas | |
| appointments:write | Crear y mover citas | |
| events:read | Leer el registro de eventos | |
| webhooks:manage | Administrar endpoints de webhook | sin ruta todavía |
| dial:write | Encolar llamadas (hace sonar un teléfono real) | modificadorhace sonar un teléfono |
| Entorno | Permiso dial:write | El lead queda |
|---|---|---|
| live | sí | en la cola — se llama |
| live | no | retenido — nadie lo llama |
| test | sí | retenido — nadie lo llama |
| test | no | retenido — 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étodo | Ruta | Permiso | Qué hace | Herramienta MCP |
|---|---|---|---|---|
| GET | /v1/leads | leads:read | La 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/:id | leads:read | Un lead por su id. Devuelve las mismas columnas que la lista.No incluye ensayos. | get_lead |
| POST | /v1/leads | leads:write | Crea uno o varios leads. Acepta un objeto suelto o { leads: [...] }, y responde con un veredicto por fila. | create_leads |
| GET | /v1/calls | calls:read | Los intentos de llamada y cómo terminó cada uno.No incluye ensayos. | list_calls |
| GET | /v1/calls/:id | calls:read | Un intento de llamada por su id.No incluye ensayos. | get_call |
| GET | /v1/conversions | conversions:read | Las conversiones: qué objetivo se persiguió, si se cumplió y los datos abiertos que ese objetivo declara.No incluye ensayos. | list_conversions |
| POST | /v1/appointments | appointments:write | Agenda una cita para un lead. Una llave de entorno test no puede usarla. | create_appointment |
| GET | /v1/appointments | appointments:read | Las 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/events | events:read | El 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/agents | brain:read | Los 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/scripts | brain:read | Las 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/knowledge | brain:read | Los 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-scripts | brain:read | Qué 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-agents | brain:read | Los 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/stages | brain:read | Las 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-proposals | brain:write | Los 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-proposals | brain:write | Propone 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/knowledge | brain:write | Crea 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_atParámetros de consulta
| Parámetro | Columna | Tipo | Operador |
|---|---|---|---|
| created_after | created_at | timestamp | gte |
| created_before | created_at | timestamp | lte |
| dial_hold | dial_hold | boolean | eq |
| external_id | external_id | text | eq |
| operator_id | operator_id | uuid | eq |
| phone | phone_e164 | text | eq |
| state | state | text | eq |
| status | status | text | eq |
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_atParámetros de consulta
| Parámetro | Columna | Tipo | Operador |
|---|---|---|---|
| created_after | created_at | timestamp | gte |
| created_before | created_at | timestamp | lte |
| lead_id | lead_id | uuid | eq |
| operator_id | operator_id | uuid | eq |
| outcome | outcome | text | eq |
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_atParámetros de consulta
| Parámetro | Columna | Tipo | Operador |
|---|---|---|---|
| created_after | created_at | timestamp | gte |
| created_before | created_at | timestamp | lte |
| lead_id | lead_id | uuid | eq |
| objective_key | objective_key | text | eq |
| passed | passed | boolean | eq |
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.
| Campo | Tipo | Obligatorio | Nota |
|---|---|---|---|
| external_id | string | no | Tu identificador. Es lo que te devolvemos en cada veredicto para que sepas qué fila es cuál. |
| first_name | string | no | Nombre. |
| last_name | string | no | Apellido. |
| phone | string | no | Se normaliza a E.164. Sin teléfono válido el lead existe pero nunca se marca. |
| string | no | Correo. | |
| state | string | no | Estado de dos letras. Decide la zona horaria y con ella las horas en que se puede llamar. |
| postal_code | string | no | Código postal. |
| hold | boolean | no | Reté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_id | uuid | no | La fuente que declara a qué consintió esta persona. |
| operator_id | uuid | no | A qué operador se asigna. Si lo omites y hay uno solo, se usa ese. |
| consent | object | no | La evidencia de consentimiento de esta persona. Sin ella, el lead puede rechazarse según la configuración de tu organización. |
{
"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.
| Campo | Tipo | Obligatorio | Nota |
|---|---|---|---|
| lead_id | uuid | sí | El lead que va a la cita. Un lead de otra organización responde 404, nunca 403. |
| scheduled_for | timestamptz | sí | Cuándo. Una fecha que no se puede leer responde invalid_parameter. |
| operator_id | uuid | no | Con 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.
{
"error": "invalid_parameter",
"message": "A query parameter was not understood.",
"request_id": "9c1f6a2e-7b40-4d18-a3e5-51d9f0c7b2aa",
"detail": "created_after"
}| Código | Estado | Mensaje |
|---|---|---|
| unauthorized | 401 | Provide a valid key as `Authorization: Bearer pgs_live_...`. |
| forbidden_scope | 403 | This key does not hold the scope this endpoint requires. |
| test_key_cannot_dial | 403 | A test-environment key cannot cause a call. The lead was not created; retry with a live key or without `dial`. |
| test_key_cannot_contact | 403 | A test-environment key cannot schedule an appointment, because an appointment sends text messages to a real telephone. |
| not_found | 404 | No such route, or no such row for this key. |
| method_not_allowed | 405 | That method is not allowed on this route. |
| invalid_body | 400 | The request body could not be read. |
| invalid_parameter | 400 | A query parameter was not understood. |
| invalid_cursor | 400 | The cursor is not one this API issued. Start the page again without it. |
| too_many_rows | 400 | The batch is larger than this endpoint accepts. |
| rate_limited | 429 | Too many requests from this key. Slow down and retry. |
| principal_unavailable | 503 | This organization's API identity is unavailable. This is our fault and it is retryable. |
| internal | 500 | Something 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ámetro | Qué hace |
|---|---|
| limit | Cuántas filas quieres. Por omisión 25, máximo 100. |
| cursor | El next_cursor de la respuesta anterior, tal cual. |
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.
| Evento | Descripción |
|---|---|
| lead.created | Un lead entró por cualquiera de las cinco vías. |
| call.completed | Un intento de llamada terminó, con su resultado. |
| call.transferred | Una transferencia en caliente conectó con el agente. |
| qualification.passed | El lead cumplió los cinco criterios. |
| qualification.failed | El lead no calificó, con el criterio que falló. |
| appointment.scheduled | Se agendó una cita. |
| compliance.opt_out | Un número entró a la lista de no llamar. |
{
"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.
{
"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_usdssnitinCada POST lleva estas cabeceras:
| Cabecera | Qué trae |
|---|---|
| x-pegasus-signature | t=<unix>,v1=<hmac-sha256 en hex>. Verifícala siempre. |
| x-pegasus-event-id | El id del evento. Es tu clave de deduplicación. |
| x-pegasus-event-type | El tipo, para enrutar sin abrir el cuerpo. |
| x-pegasus-delivery-id | Este envío en particular. Cambia en cada reintento. |
| x-pegasus-attempt | Número de intento, empezando en 1. |
| user-agent | Pegasus-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.
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"));
}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.
claude mcp add --transport http pegasus \
"$PEGASUS_MCP" \
--header "Authorization: Bearer $PEGASUS_KEY"| Herramienta MCP | Permiso | Ruta |
|---|---|---|
| list_leads | leads:read | GET /v1/leads |
| get_lead | leads:read | GET /v1/leads/:id |
| create_leads | leads:write | POST /v1/leads |
| list_calls | calls:read | GET /v1/calls |
| get_call | calls:read | GET /v1/calls/:id |
| list_conversions | conversions:read | GET /v1/conversions |
| create_appointment | appointments:write | POST /v1/appointments |
| list_appointments | appointments:read | GET /v1/appointments |
| list_events | events:read | GET /v1/events |
| list_agents | brain:read | GET /v1/agents |
| list_scripts | brain:read | GET /v1/scripts |
| list_knowledge | brain:read | GET /v1/knowledge |
| list_call_scripts | brain:read | GET /v1/call-scripts |
| list_stage_agents | brain:read | GET /v1/stage-agents |
| list_stages | brain:read | GET /v1/stages |
| list_agent_proposals | brain:write | GET /v1/agent-proposals |
| propose_agent_change | brain:write | POST /v1/agent-proposals |
| save_knowledge_draft | brain:write | POST /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.