Reference

Documentation

Everything you need to integrate with Pegasus: the REST API, signed webhooks, and the MCP server.

Quickstart

From a fresh key to your first response.

The Pegasus API is REST over HTTPS with JSON in both directions. The same key opens both the REST API and the MCP server — there are not two credentials to manage.

  1. Sign in to the console and open Desarrolladores → Llaves.
  2. Create a key. Choose the test environment while you build: a test key can never make a telephone ring.
  3. Check only the scopes your integration needs. There are no wildcards and none implies another.
  4. Copy the key right then. It is shown exactly once and there is no way to see it again.

The two base URLs. The examples on this page use $PEGASUS_API for the first, $PEGASUS_MCP for the second and $PEGASUS_KEY for your key, so you can export those and copy the commands as they are.

Your base URL is shown in the console, under Desarrolladores → API.

Your first request. Returns the first page of leads, newest first.
bash
curl -s "$PEGASUS_API/v1/leads?limit=2" \
  -H "Authorization: Bearer $PEGASUS_KEY"
The response. Every list answers with the same envelope: data and 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"
}

Authentication and scopes

Keys, environments, and what each scope opens.

Every request carries the key in the Authorization header. There are no query-string keys, no cookies and no sessions: a request without a valid header gets a 401 and nothing else.

The header you send, and the one we return on every response.
http
Authorization: Bearer pgs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

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

Keep the x-pegasus-request-id from any response that surprises you. It is the same identifier that appears in the console log and in the request_id field of every error, and it is the first thing we will ask you for.

EnvironmentPrefix
livepgs_live_
testpgs_test_

A live key is the production one: with dial:write, the leads it creates enter the queue and a real telephone rings. A test key writes to the same data but cannot cause contact: every lead it creates is held, and it cannot schedule appointments, whatever scopes it holds.

Scopes are a closed set. There are no wildcards and none implies another: leads:write does not let you read leads. Asking for a scope that does not exist fails at key creation, not at your first request.

ScopeWhat it does
leads:readRead leads and their status
leads:writeCreate and update leads
calls:readRead calls and their outcomes
conversions:readRead conversions (without the restricted data)
brain:readRead the agent's brain (script, knowledge, criteria)
brain:writePropose changes to an agent and draft knowledge (a person publishes them)
appointments:readRead appointments
appointments:writeCreate and move appointments
events:readRead the event log
webhooks:manageManage webhook endpointsno route yet
dial:writeQueue calls (makes a real telephone ring)modifiermakes a telephone ring
Environmentdial:write scopeThe lead arrives
liveyesqueued — it is called
livenoheld — nobody calls it
testyesheld — nobody calls it
testnoheld — nobody calls it

Splitting the two is not decoration: leads:write creates a row, dial:write makes a phone ring in someone's house, and TCPA liability attaches to the second. An integration that only feeds a CRM cannot reach it by accident.

Endpoints

The routes, their fields and their filters.

Every route lives under /v1. Each requires exactly one scope, resolved before the request reaches its handler: a route that names no scope does not compile, so there is no accidentally open route.

MethodPathScopeWhat it doesMCP tool
GET/v1/leadsleads:readThe list of leads, newest first, with filters and cursor pagination.Excludes rehearsals.list_leads
GET/v1/leads/:idleads:readOne lead by id. Returns the same columns as the list.Excludes rehearsals.get_lead
POST/v1/leadsleads:writeCreates one or many leads. Accepts a bare object or { leads: [...] }, and answers with a per-row verdict.create_leads
GET/v1/callscalls:readCall attempts and how each one ended.Excludes rehearsals.list_calls
GET/v1/calls/:idcalls:readOne call attempt by id.Excludes rehearsals.get_call
GET/v1/conversionsconversions:readConversions: which objective was pursued, whether it was met, and the open facts that objective declares.Excludes rehearsals.list_conversions
POST/v1/appointmentsappointments:writeSchedules an appointment for a lead. A test-environment key cannot use it.create_appointment
GET/v1/appointmentsappointments:readBooked appointments. `source` says who booked it: the agent because nobody was free, the agent because a transfer did not connect, a person, or a connected calendar.Excludes rehearsals.list_appointments
GET/v1/eventsevents:readThe organization's event log. Each `payload` is the same body a webhook receives, built field by field: it carries no transcripts, no recordings, no health answers and no consent evidence.Excludes rehearsals.list_events
GET/v1/agentsbrain:readThe organization's AI agents. `config` is the criteria it qualifies against, in the terms of the objective it is pinned to. It is what makes a conversion's `failed_criterion` mean something.list_agents
GET/v1/scriptsbrain:readScript variants: the opener, the discovery, the close and the objection rebuttals. Only a `published` variant can reach a telephone, and only as an arm of a running experiment.list_scripts
GET/v1/knowledgebrain:readThe facts the agent may state, as question-and-answer pairs. Only `published` ones reach a call. Publishing is a person's act in the console; `brain:write` can save a draft (POST /v1/knowledge) and nothing more.list_knowledge
GET/v1/call-scriptsbrain:readWhich script each call attempt read. Join it to /v1/calls on `call_attempt_id` and to /v1/scripts on `script_variant_id`. Mind `script_resolution`: a `fallback` row belongs to no arm and must be dropped before computing a rate per variant. It carries the stamp on the prompt, never the prompt.Excludes rehearsals.list_call_scripts
GET/v1/stage-agentsbrain:readThe organization's stage agents: who each one is when it speaks (`persona`) and what it is for (`purpose`). Only an `active` one works leads. Its stages are at /v1/stages.list_stage_agents
GET/v1/stagesbrain:readAn agent's stages; filter by `ai_agent_id`. `script` is what the agent says in that part and `required_fields` is the gate: what must have been collected to leave it. The gate is read, never proposed.list_stages
GET/v1/agent-proposalsbrain:writeThe changes proposed to stage agents through this API, with their `status`: `proposed` is waiting for a person, `applied` is live, `rejected` was declined. `before` is the text it was written against.list_agent_proposals
POST/v1/agent-proposalsbrain:writeProposes new wording for a stage agent. It changes nothing: it files a proposal that an agency admin applies or rejects in the console. With `stage_id`, `changes` accepts `script`, `entry_hint` and `name`; without it, `name`, `purpose` and `persona`. Gates, order, tools and switching the agent on or off are not proposable.propose_agent_change
POST/v1/knowledgebrain:writeCreates a fact as a draft, or edits one that is still a draft (with `id`). A draft is never said on a call: a person publishes it in the console. A published one cannot be edited from here; save a new draft instead.save_knowledge_draft

Lists answer with { data, next_cursor }. Reads by id answer with { data }. Writes answer with their own summary, described below.

Leads. The columns it returns and the parameters you can filter by:

Response fields

idexternal_idfirst_namelast_namephone_e164emailstatepostal_codetimezonetimezone_confidencestatusdisqualify_reasonattempt_countlast_attempt_atdial_holdoperator_idlead_source_idcreated_by_api_keycreated_atupdated_at

Query parameters

ParameterColumnTypeOperator
created_aftercreated_attimestampgte
created_beforecreated_attimestamplte
dial_holddial_holdbooleaneq
external_idexternal_idtexteq
operator_idoperator_iduuideq
phonephone_e164texteq
statestatetexteq
statusstatustexteq

experiment_arm is absent, and not by oversight: the arm is assigned by the platform, and nothing about a control group is the caller's business.

Calls. Each attempt, with its outcome and duration:

Response fields

idlead_idoperator_idattempt_numberprovider_keydial_modeoutcomeduration_secondsreview_requiredstarted_atended_atcreated_at

Query parameters

ParameterColumnTypeOperator
created_aftercreated_attimestampgte
created_beforecreated_attimestamplte
lead_idlead_iduuideq
operator_idoperator_iduuideq
outcomeoutcometexteq

Conversions. An objective declares which variables it gathers and the conversion stores them in facts, so the shape of that object depends on objective_key and objective_version — both ride on the same row so you can read it without guessing:

Response fields

idlead_idoperator_idagent_idcall_attempt_idobjective_keyobjective_versionconversion_schema_versionfactspassedfailed_criteriontranscript_completeconverted_atcreated_at

Query parameters

ParameterColumnTypeOperator
created_aftercreated_attimestampgte
created_beforecreated_attimestamplte
lead_idlead_iduuideq
objective_keyobjective_keytexteq
passedpassedbooleaneq

Creating leads. Accepts a bare object or { leads: [...] }, up to 500 per request:

Fields on each lead. All optional unless your organization requires otherwise; a lead with no phone is accepted but cannot be called.

FieldTypeRequiredNote
external_idstringnoYour own identifier. It is echoed in every verdict so you can tell which row is which.
first_namestringnoGiven name.
last_namestringnoFamily name.
phonestringnoNormalized to E.164. With no valid phone the lead exists but is never dialed.
emailstringnoEmail address.
statestringnoTwo-letter state. Decides the timezone, and with it the hours when calling is allowed.
postal_codestringnoPostal code.
holdbooleannoHold this lead even if your key may dial. Holding is the safe direction: if either you or the key insists on holding, it holds.
lead_source_iduuidnoThe source that declares what this person consented to.
operator_iduuidnoWhich operator it is assigned to. Omit it and, if there is exactly one, that one is used.
consentobjectnoThis person's consent evidence. Without it the lead may be rejected, depending on your organization's configuration.
The response to a batch write. One bad row does not take the others with it: the batch answers 200 with a verdict per row.
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" }
  ]
}

Each row carries status created, duplicate or rejected. A duplicate says which of the two rules caught it — the external_id was already received, or a live lead already has that number — and a rejected carries the reason the database gave. dialing is the key's verdict for the whole batch: queued if these leads enter the queue, held if not. held_by_request counts the ones you held with hold even though the key could have dialed. Read dialing rather than inferring it from scopes.

Scheduling an appointment. Two required fields:

Body of POST /v1/appointments.

FieldTypeRequiredNote
lead_iduuidyesThe lead the appointment is for. A lead in another organization answers 404, never 403.
scheduled_fortimestamptzyesWhen. A date that cannot be parsed answers invalid_parameter.
operator_iduuidnoWhich operator. Omit it and, if there is exactly one, that one is used; with several, it is required.

Errors

What a refusal looks like, and what to do with each one.

Every error answers with the same shape. Branch on error, which is a stable code, never on message, which is a sentence for humans and may change.

An error body. detail appears only when there is something specific to point at, such as the parameter that was not understood.
json
{
  "error": "invalid_parameter",
  "message": "A query parameter was not understood.",
  "request_id": "9c1f6a2e-7b40-4d18-a3e5-51d9f0c7b2aa",
  "detail": "created_after"
}
CodeStatusMessage
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 is reserved and not emitted yet. Handle it now anyway: when a limiter exists, your integration will not have to change to find out.

503 principal_unavailable is ours and it is retryable. 500 internal is ours too: keep the request_id and tell us.

Pagination

Keyset cursors, and why there is no offset.

Every list orders by (created_at desc, id desc) and pages by cursor. There is no offset and no page, deliberately: leads keep arriving while you walk the pages, and an offset would let each new lead push a row from page 2 to page 3 where you never see it.

The id breaks ties, because two leads imported in the same millisecond are common and a cursor that cannot separate them either repeats rows or skips them.

ParameterWhat it does
limitHow many rows you want. Defaults to 25, maximum 100.
cursorThe next_cursor from the previous response, verbatim.
Walking every page. A null next_cursor is the end; there is no other signal to look for.
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 comes back null when the page returned fewer rows than the limit you asked for, which is how you know you reached the end. Even so, stop on next_cursor rather than by counting rows: it is one condition, and it does not change if we ever page differently.

Webhooks

Signed events, retries, and how to verify the signature.

Register an https endpoint in the console, check the events you care about, and we POST a signed request each time one happens. The signing secret is shown exactly once, like the key.

The event set is closed. An event type is a promise: once somebody's CRM switches on qualification.passed, renaming it breaks someone's payroll. There are no custom events.

EventDescription
lead.createdA lead arrived, by any of the five routes in.
call.completedA call attempt ended, with its outcome.
call.transferredA warm transfer connected to the agent.
qualification.passedThe lead met all five criteria.
qualification.failedThe lead did not qualify, with the criterion that failed.
appointment.scheduledAn appointment was booked.
compliance.opt_outA number entered the do-not-call list.
The envelope. It is always the same; data varies by type. id identifies the EVENT, delivery_id this particular delivery attempt.
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"
  }
}

Notice what that payload does NOT carry: no name and no phone number. Contact fields ride on lead.created, which is the event that has them; the others refer to the lead by lead_id. If your CRM needs the phone number at qualification time, store it when lead.created arrives, or fetch it with GET /v1/leads/:id.

lead.created, which does carry the contact fields: they are already yours, and a lead event with no phone number cannot do the one job a CRM integration has.
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"
  }
}

Never sent

a1chealthtranscriptrecordingconsent_evidence_urlcost_usdssnitin

Every POST carries these headers:

HeaderWhat it carries
x-pegasus-signaturet=<unix>,v1=<hex hmac-sha256>. Always verify it.
x-pegasus-event-idThe event id. This is your deduplication key.
x-pegasus-event-typeThe type, so you can route without parsing the body.
x-pegasus-delivery-idThis particular delivery. It changes on every retry.
x-pegasus-attemptAttempt number, starting at 1.
user-agentPegasus-Webhooks/1

We sign the string `${t}.${body}` with HMAC-SHA256 using your endpoint's secret, in hex. Verify against the raw body, byte for byte: if your framework already parsed the JSON and you re-serialized it, the signature will not match even though the content is identical.

Verification in Node, no dependencies. Constant-time compare, and stale requests rejected.
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"));
}
In Express, keep the raw body before anything parses it.
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));
  },
);

An event is attempted 6 times in total: the first delivery and 5 retries — after a minute, 5 minutes, half an hour, 2 hours and 6 hours. That is about eight hours, enough for a deploy, a certificate renewal and a night's sleep, without holding a row for a week. After 20 consecutive failures we switch the endpoint off and the console says so: somebody who moved their server should be told, not retried at forever.

Any 2xx counts as delivered. Everything else, a timeout included, counts as a failure and enters the retry ladder.

MCP

Connecting a model to the same API, with the same key.

Pegasus also speaks MCP over streamable HTTP. It is the same API underneath: the same routes, the same scopes, the same key in the same header. There is no separate credential to manage.

One line in Claude Code. In other clients, the same URL and the same header.
bash
claude mcp add --transport http pegasus \
  "$PEGASUS_MCP" \
  --header "Authorization: Bearer $PEGASUS_KEY"
MCP toolScopeRoute
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

The server is stateless and initiates nothing: GET answers 405 because there is no SSE stream to open, and there is no sampling and there are no subscriptions. An API refusal arrives as a tool result marked isError rather than as a transport error, so the model can read it and correct course instead of being left in the dark.

Something here not matching what you see? Keep the x-pegasus-request-id and tell us.