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.
- Sign in to the console and open Desarrolladores → Llaves.
- Create a key. Choose the test environment while you build: a test key can never make a telephone ring.
- Check only the scopes your integration needs. There are no wildcards and none implies another.
- 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.
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"
}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.
Authorization: Bearer pgs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
x-pegasus-request-id: 9c1f6a2e-7b40-4d18-a3e5-51d9f0c7b2aaKeep 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.
| Environment | Prefix |
|---|---|
| live | pgs_live_ |
| test | pgs_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.
| Scope | What it does | |
|---|---|---|
| leads:read | Read leads and their status | |
| leads:write | Create and update leads | |
| calls:read | Read calls and their outcomes | |
| conversions:read | Read conversions (without the restricted data) | |
| brain:read | Read the agent's brain (script, knowledge, criteria) | |
| brain:write | Propose changes to an agent and draft knowledge (a person publishes them) | |
| appointments:read | Read appointments | |
| appointments:write | Create and move appointments | |
| events:read | Read the event log | |
| webhooks:manage | Manage webhook endpoints | no route yet |
| dial:write | Queue calls (makes a real telephone ring) | modifiermakes a telephone ring |
| Environment | dial:write scope | The lead arrives |
|---|---|---|
| live | yes | queued — it is called |
| live | no | held — nobody calls it |
| test | yes | held — nobody calls it |
| test | no | held — 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.
| Method | Path | Scope | What it does | MCP tool |
|---|---|---|---|---|
| GET | /v1/leads | leads:read | The list of leads, newest first, with filters and cursor pagination.Excludes rehearsals. | list_leads |
| GET | /v1/leads/:id | leads:read | One lead by id. Returns the same columns as the list.Excludes rehearsals. | get_lead |
| POST | /v1/leads | leads:write | Creates one or many leads. Accepts a bare object or { leads: [...] }, and answers with a per-row verdict. | create_leads |
| GET | /v1/calls | calls:read | Call attempts and how each one ended.Excludes rehearsals. | list_calls |
| GET | /v1/calls/:id | calls:read | One call attempt by id.Excludes rehearsals. | get_call |
| GET | /v1/conversions | conversions:read | Conversions: which objective was pursued, whether it was met, and the open facts that objective declares.Excludes rehearsals. | list_conversions |
| POST | /v1/appointments | appointments:write | Schedules an appointment for a lead. A test-environment key cannot use it. | create_appointment |
| GET | /v1/appointments | appointments:read | Booked 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/events | events:read | The 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/agents | brain:read | The 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/scripts | brain:read | Script 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/knowledge | brain:read | The 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-scripts | brain:read | Which 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-agents | brain:read | The 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/stages | brain:read | An 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-proposals | brain:write | The 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-proposals | brain:write | Proposes 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/knowledge | brain:write | Creates 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_atQuery parameters
| Parameter | Column | Type | Operator |
|---|---|---|---|
| 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 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_atQuery parameters
| Parameter | Column | Type | Operator |
|---|---|---|---|
| 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 |
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_atQuery parameters
| Parameter | Column | Type | Operator |
|---|---|---|---|
| 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 |
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.
| Field | Type | Required | Note |
|---|---|---|---|
| external_id | string | no | Your own identifier. It is echoed in every verdict so you can tell which row is which. |
| first_name | string | no | Given name. |
| last_name | string | no | Family name. |
| phone | string | no | Normalized to E.164. With no valid phone the lead exists but is never dialed. |
| string | no | Email address. | |
| state | string | no | Two-letter state. Decides the timezone, and with it the hours when calling is allowed. |
| postal_code | string | no | Postal code. |
| hold | boolean | no | Hold 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_id | uuid | no | The source that declares what this person consented to. |
| operator_id | uuid | no | Which operator it is assigned to. Omit it and, if there is exactly one, that one is used. |
| consent | object | no | This person's consent evidence. Without it the lead may be rejected, depending on your organization's configuration. |
{
"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.
| Field | Type | Required | Note |
|---|---|---|---|
| lead_id | uuid | yes | The lead the appointment is for. A lead in another organization answers 404, never 403. |
| scheduled_for | timestamptz | yes | When. A date that cannot be parsed answers invalid_parameter. |
| operator_id | uuid | no | Which 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.
{
"error": "invalid_parameter",
"message": "A query parameter was not understood.",
"request_id": "9c1f6a2e-7b40-4d18-a3e5-51d9f0c7b2aa",
"detail": "created_after"
}| Code | Status | Message |
|---|---|---|
| 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 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.
| Parameter | What it does |
|---|---|
| limit | How many rows you want. Defaults to 25, maximum 100. |
| cursor | The next_cursor from the previous response, verbatim. |
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.
| Event | Description |
|---|---|
| lead.created | A lead arrived, by any of the five routes in. |
| call.completed | A call attempt ended, with its outcome. |
| call.transferred | A warm transfer connected to the agent. |
| qualification.passed | The lead met all five criteria. |
| qualification.failed | The lead did not qualify, with the criterion that failed. |
| appointment.scheduled | An appointment was booked. |
| compliance.opt_out | A number entered the do-not-call list. |
{
"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.
{
"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_usdssnitinEvery POST carries these headers:
| Header | What it carries |
|---|---|
| x-pegasus-signature | t=<unix>,v1=<hex hmac-sha256>. Always verify it. |
| x-pegasus-event-id | The event id. This is your deduplication key. |
| x-pegasus-event-type | The type, so you can route without parsing the body. |
| x-pegasus-delivery-id | This particular delivery. It changes on every retry. |
| x-pegasus-attempt | Attempt number, starting at 1. |
| user-agent | Pegasus-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.
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));
},
);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.
claude mcp add --transport http pegasus \
"$PEGASUS_MCP" \
--header "Authorization: Bearer $PEGASUS_KEY"| MCP tool | Scope | Route |
|---|---|---|
| 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 |
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.