Overview

The AgentCalls API lets your systems start outbound calls, read calls and transcripts, manage contacts and receive webhooks when calls finish. JSON in, JSON out, over HTTPS.

Base URL: https://agentcalls.net/api/v1. Create keys and webhook endpoints in the dashboard under Settings → Developers.

curl https://agentcalls.net/api/v1/agents \
  -H "Authorization: Bearer $AGENTCALLS_API_KEY"

Building with an AI agent (Claude, Cursor, Claude Code)? The same keys work on the MCP server, which adds agent building, number purchase and voice browsing as tools.

Authentication

Create an API key in the dashboard under Settings → Developers. The full key (ak_live_…) is shown once; we store only a hash.

Send it on every request as Authorization: Bearer ak_live_…. Keys are scoped: pick only the scopes an integration needs. A key without the scope gets 403 forbidden; an unknown or revoked key gets 401 unauthorized.

Keys belong to one organization and only ever see that organization's data. Revoking a key takes effect immediately.

ScopeGrants
calls:readGET /calls, GET /calls/{id} (incl. recording links)
calls:writePOST /calls
contacts:readGET /contacts, GET /contacts/{id}
contacts:writePOST /contacts, PATCH /contacts/{id}, DELETE /contacts/{id}
agents:readGET /agents
numbers:readGET /numbers
agents:writeMCP only: create_agent, update_agent, publish_agent
numbers:writeMCP only: buy_number, assign_number
voices:readMCP only: list_voices
usage:readMCP only: get_usage
webhooks:writeMCP only: create_webhook

Rate limits

Each key may make 60 requests per minute (token bucket: a burst of up to 60, refilling at one request per second). The limit is enforced per server instance, so under load you may briefly see slightly more headroom, never less.

Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; a 429 rate_limited also carries Retry-After (seconds). Back off and retry; replays of POST /calls are safe with an Idempotency-Key.

Outbound calls are additionally limited by your plan's concurrency: POST /calls answers 429 concurrency_limit while every line is busy.

Idempotency

POST /calls accepts an optional Idempotency-Key header (1–128 printable ASCII characters, unique per intended call, e.g. your order id). If a call with that key already exists in your organization the original call is returned with status 200 and Idempotent-Replayed: true; no second call is placed.

Keys never expire. Two requests with the same key arriving within the same few milliseconds may both go through; retry only after the first attempt has returned or timed out.

Pagination

List endpoints return { data: [...], nextCursor }. Pass nextCursor back as ?cursor= to fetch the next page; null means you reached the end. Cursors are opaque and tied to the sort order (newest first). limit is 1–100 (default 50).

GET /agents and GET /numbers return the full list in one page (nextCursor is always null).

{
  "data": [
    "…"
  ],
  "nextCursor": "djE6NjhlZjNiMmMwMDFhOWQ0ZTdmMTA"
}

Conventions

All timestamps are ISO 8601 in UTC with millisecond precision (2026-10-15T12:00:03.000Z). Phone numbers are E.164 (+14155550123); anything else is rejected with 400.

Money is in US cents as integers. Ids are opaque strings.

Responses are never cached (Cache-Control: no-store). The API does not send CORS headers: call it from your server, never from a browser, so your key stays secret.

Endpoints

MethodPathScopeSummary
POST/callscalls:writeStart an outbound call
GET/callscalls:readList calls
GET/calls/{id}calls:readGet a call
GET/contactscontacts:readList contacts
POST/contactscontacts:writeCreate a contact
GET/contacts/{id}contacts:readGet a contact
PATCH/contacts/{id}contacts:writeUpdate a contact
DELETE/contacts/{id}contacts:writeDelete a contact
GET/agentsagents:readList published agents
GET/numbersnumbers:readList phone numbers
POST/api/v1/callsscope calls:write

Start an outbound call

Dials to from one of your active numbers with a published agent. The call is created in status queued and returned immediately; follow it with GET /calls/{id} or the call.ended webhook. Send an Idempotency-Key header to make retries safe: a repeat with the same key returns the first call with status 200 and Idempotent-Replayed: true.

ParameterInRequiredDescription
Idempotency-KeyheadernoOptional. Unique per call attempt (e.g. your order id). Printable ASCII, 1–128 chars.

Request body (CreateCallRequest)

{
  "agentId": "6ad0bf49171339b53c8e",
  "phoneNumberId": "68ee11aa00c1d2e3f405",
  "to": "+14155550123",
  "contactName": "Sara Ahmed"
}

Example request

curl -X POST https://agentcalls.net/api/v1/calls \
  -H "Authorization: Bearer $AGENTCALLS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-8841-confirm" \
  -d '{"agentId":"6ad0bf49171339b53c8e","phoneNumberId":"68ee11aa00c1d2e3f405","to":"+14155550123"}'
StatusDescription
201Call queued.
200Idempotent replay of an earlier request.
400Validation failed (invalid_request).
402Billing gate: no minutes or balance, or the organization is suspended (payment_required).
403Contact is do-not-call (contact_do_not_call) or the destination country is not allowed (country_not_allowed).
404Agent or number not found (not_found).
409agent_not_published, number_not_active or number_inbound_only.
429All concurrent lines busy (concurrency_limit), or API rate limit (rate_limited). Retry-After is set.
502The voice provider refused the call (call_start_failed); a failed call row is kept.

Example 201 response

{
  "data": {
    "id": "68ef3b2c001a9d4e7f10",
    "agentId": "6ad0bf49171339b53c8e",
    "agentVersion": 3,
    "contactId": "68ef3a91002b7c3d5e01",
    "phoneNumberId": "68ee11aa00c1d2e3f405",
    "direction": "outbound",
    "from": "+13145550199",
    "to": "+14155550123",
    "status": "queued",
    "startedAt": "2026-10-15T12:00:03.000Z",
    "answeredAt": null,
    "endedAt": null,
    "durationSec": 0,
    "billableSec": 0,
    "costCents": 0,
    "voiceTier": "standard",
    "outcome": null,
    "sentiment": null,
    "endedReason": null,
    "consentPlayed": true,
    "idempotencyKey": "order-8841-confirm",
    "createdAt": "2026-10-15T12:00:03.000Z"
  }
}
GET/api/v1/callsscope calls:read

List calls

Newest first. Combine filters freely; since/until compare against startedAt.

ParameterInRequiredDescription
agentIdquerynoOnly calls handled by this agent.
statusquerynoOnly calls in this state.
sincequerynoStarted at or after this ISO 8601 instant.
untilquerynoStarted at or before this ISO 8601 instant.
contactquerynoA contact id, or an E.164 phone number (resolved to the contact).
limitquerynoPage size (1–100, default 50).
cursorquerynonextCursor from the previous page. Opaque.

Example request

curl "https://agentcalls.net/api/v1/calls?status=ended&since=2026-10-01T00:00:00Z&limit=20" \
  -H "Authorization: Bearer $AGENTCALLS_API_KEY"
StatusDescription
200A page of calls.
400Bad filter or cursor (invalid_request, invalid_cursor).

Example 200 response

{
  "data": [
    {
      "id": "68ef3b2c001a9d4e7f10",
      "agentId": "6ad0bf49171339b53c8e",
      "agentVersion": 3,
      "contactId": "68ef3a91002b7c3d5e01",
      "phoneNumberId": "68ee11aa00c1d2e3f405",
      "direction": "outbound",
      "from": "+13145550199",
      "to": "+14155550123",
      "status": "ended",
      "startedAt": "2026-10-15T12:00:03.000Z",
      "answeredAt": "2026-10-15T12:00:09.000Z",
      "endedAt": "2026-10-15T12:03:41.000Z",
      "durationSec": 212,
      "billableSec": 216,
      "costCents": 100,
      "voiceTier": "standard",
      "outcome": "appointment_booked",
      "sentiment": "positive",
      "endedReason": "customer-ended-call",
      "consentPlayed": true,
      "idempotencyKey": "order-8841-confirm",
      "createdAt": "2026-10-15T12:00:03.000Z"
    }
  ],
  "nextCursor": "djE6NjhlZjNiMmMwMDFhOWQ0ZTdmMTA"
}
GET/api/v1/calls/{id}scope calls:read

Get a call

Everything in the list item plus transcript, summary, structured data, tool calls and a signed recording link that expires after 10 minutes.

ParameterInRequiredDescription
idpathyesCall id.

Example request

curl https://agentcalls.net/api/v1/calls/68ef3b2c001a9d4e7f10 \
  -H "Authorization: Bearer $AGENTCALLS_API_KEY"
StatusDescription
200The call.
404Unknown id, or a call of another organization.

Example 200 response

{
  "data": {
    "id": "68ef3b2c001a9d4e7f10",
    "agentId": "6ad0bf49171339b53c8e",
    "agentVersion": 3,
    "contactId": "68ef3a91002b7c3d5e01",
    "phoneNumberId": "68ee11aa00c1d2e3f405",
    "direction": "outbound",
    "from": "+13145550199",
    "to": "+14155550123",
    "status": "ended",
    "startedAt": "2026-10-15T12:00:03.000Z",
    "answeredAt": "2026-10-15T12:00:09.000Z",
    "endedAt": "2026-10-15T12:03:41.000Z",
    "durationSec": 212,
    "billableSec": 216,
    "costCents": 100,
    "voiceTier": "standard",
    "outcome": "appointment_booked",
    "sentiment": "positive",
    "endedReason": "customer-ended-call",
    "consentPlayed": true,
    "idempotencyKey": "order-8841-confirm",
    "createdAt": "2026-10-15T12:00:03.000Z",
    "transcript": [
      {
        "role": "agent",
        "text": "Hi, this is Mia from Northwind Clinic. Am I speaking with Sara?",
        "at": 0.8
      },
      {
        "role": "user",
        "text": "Yes, that's me.",
        "at": 4.1
      }
    ],
    "summary": "Confirmed Sara's appointment for Thursday 10:00 and noted a parking question.",
    "structuredData": {
      "appointment_confirmed": true,
      "follow_up": "parking directions"
    },
    "toolCalls": [
      {
        "name": "lookup_contact",
        "kind": "builtin",
        "ok": true,
        "durationMs": 184,
        "at": "2026-10-15T12:00:11.000Z",
        "args": {}
      }
    ],
    "recording": {
      "url": "https://agentcalls.net/api/v1/calls/68ef3b2c001a9d4e7f10/recording?exp=1760530421&sig=…",
      "expiresAt": "2026-10-15T12:13:41.000Z"
    }
  }
}
GET/api/v1/contactsscope contacts:read

List contacts

Newest first.

ParameterInRequiredDescription
phonequerynoExact E.164 match (at most one result).
tagquerynoContacts carrying this tag.
doNotCallquerynoFilter by do-not-call flag.
limitquerynoPage size (1–100, default 50).
cursorquerynonextCursor from the previous page. Opaque.

Example request

curl "https://agentcalls.net/api/v1/contacts?tag=vip" \
  -H "Authorization: Bearer $AGENTCALLS_API_KEY"
StatusDescription
200A page of contacts.

Example 200 response

{
  "data": [
    {
      "id": "68ef3a91002b7c3d5e01",
      "phone": "+14155550123",
      "name": "Sara Ahmed",
      "email": "sara@example.com",
      "attributes": {
        "plan": "pro",
        "city": "Dubai"
      },
      "tags": [
        "vip"
      ],
      "notes": "Prefers mornings.",
      "memorySummary": "Booked a Thursday appointment; asked about parking.",
      "lastCallAt": "2026-10-15T12:03:41.000Z",
      "callCount": 3,
      "doNotCall": false,
      "createdAt": "2026-09-02T08:14:00.000Z",
      "updatedAt": "2026-10-15T12:03:42.000Z"
    }
  ],
  "nextCursor": null
}
POST/api/v1/contactsscope contacts:write

Create a contact

Phone numbers are unique per organization.

Request body (CreateContactRequest)

{
  "phone": "+14155550123",
  "name": "Sara Ahmed",
  "email": "sara@example.com",
  "attributes": {
    "plan": "pro"
  },
  "tags": [
    "vip"
  ]
}

Example request

curl -X POST https://agentcalls.net/api/v1/contacts \
  -H "Authorization: Bearer $AGENTCALLS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"phone":"+14155550123","name":"Sara Ahmed","tags":["vip"]}'
StatusDescription
201Created.
400Validation failed.
409A contact with this phone exists (conflict, details.id has it).

Example 201 response

{
  "data": {
    "id": "68ef3a91002b7c3d5e01",
    "phone": "+14155550123",
    "name": "Sara Ahmed",
    "email": "sara@example.com",
    "attributes": {
      "plan": "pro",
      "city": "Dubai"
    },
    "tags": [
      "vip"
    ],
    "notes": "Prefers mornings.",
    "memorySummary": "Booked a Thursday appointment; asked about parking.",
    "lastCallAt": "2026-10-15T12:03:41.000Z",
    "callCount": 3,
    "doNotCall": false,
    "createdAt": "2026-09-02T08:14:00.000Z",
    "updatedAt": "2026-10-15T12:03:42.000Z"
  }
}
GET/api/v1/contacts/{id}scope contacts:read

Get a contact

ParameterInRequiredDescription
idpathyesContact id.

Example request

curl https://agentcalls.net/api/v1/contacts/68ef3a91002b7c3d5e01 \
  -H "Authorization: Bearer $AGENTCALLS_API_KEY"
StatusDescription
200The contact.
404Not found.

Example 200 response

{
  "data": {
    "id": "68ef3a91002b7c3d5e01",
    "phone": "+14155550123",
    "name": "Sara Ahmed",
    "email": "sara@example.com",
    "attributes": {
      "plan": "pro",
      "city": "Dubai"
    },
    "tags": [
      "vip"
    ],
    "notes": "Prefers mornings.",
    "memorySummary": "Booked a Thursday appointment; asked about parking.",
    "lastCallAt": "2026-10-15T12:03:41.000Z",
    "callCount": 3,
    "doNotCall": false,
    "createdAt": "2026-09-02T08:14:00.000Z",
    "updatedAt": "2026-10-15T12:03:42.000Z"
  }
}
PATCH/api/v1/contacts/{id}scope contacts:write

Update a contact

Partial update. attributes merge into the stored map (empty string clears a key); tags replace the list.

ParameterInRequiredDescription
idpathyesContact id.

Request body (UpdateContactRequest)

{
  "attributes": {
    "city": "Dubai"
  },
  "doNotCall": false
}

Example request

curl -X PATCH https://agentcalls.net/api/v1/contacts/68ef3a91002b7c3d5e01 \
  -H "Authorization: Bearer $AGENTCALLS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"doNotCall":true}'
StatusDescription
200Updated contact.
400Validation failed.
404Not found.
409The new phone belongs to another contact.

Example 200 response

{
  "data": {
    "id": "68ef3a91002b7c3d5e01",
    "phone": "+14155550123",
    "name": "Sara Ahmed",
    "email": "sara@example.com",
    "attributes": {
      "plan": "pro",
      "city": "Dubai"
    },
    "tags": [
      "vip"
    ],
    "notes": "Prefers mornings.",
    "memorySummary": "Booked a Thursday appointment; asked about parking.",
    "lastCallAt": "2026-10-15T12:03:41.000Z",
    "callCount": 3,
    "doNotCall": false,
    "createdAt": "2026-09-02T08:14:00.000Z",
    "updatedAt": "2026-10-15T12:03:42.000Z"
  }
}
DELETE/api/v1/contacts/{id}scope contacts:write

Delete a contact

Past calls keep their contactId; the agent's memory of the contact is deleted with it.

ParameterInRequiredDescription
idpathyesContact id.

Example request

curl -X DELETE https://agentcalls.net/api/v1/contacts/68ef3a91002b7c3d5e01 \
  -H "Authorization: Bearer $AGENTCALLS_API_KEY"
StatusDescription
204Deleted.
404Not found.
GET/api/v1/agentsscope agents:read

List published agents

Only agents with a published version. Instructions, tools and voice ids are not exposed.

Example request

curl https://agentcalls.net/api/v1/agents \
  -H "Authorization: Bearer $AGENTCALLS_API_KEY"
StatusDescription
200All published agents (not paginated).

Example 200 response

{
  "data": [
    {
      "id": "6ad0bf49171339b53c8e",
      "name": "Front desk",
      "status": "live",
      "language": "en",
      "publishedVersion": 3,
      "voiceTier": "standard",
      "firstMessage": "Hello! How can I help you today?",
      "createdAt": "2026-09-01T10:00:00.000Z"
    }
  ],
  "nextCursor": null
}
GET/api/v1/numbersscope numbers:read

List phone numbers

Your numbers (released ones excluded). Use an active number with inboundOnly: false as the caller id for POST /calls.

Example request

curl https://agentcalls.net/api/v1/numbers \
  -H "Authorization: Bearer $AGENTCALLS_API_KEY"
StatusDescription
200All numbers (not paginated).

Example 200 response

{
  "data": [
    {
      "id": "68ee11aa00c1d2e3f405",
      "phone": "+13145550199",
      "country": "US",
      "type": "local",
      "status": "active",
      "agentId": "6ad0bf49171339b53c8e",
      "inboundOnly": false,
      "createdAt": "2026-09-01T10:05:00.000Z"
    }
  ],
  "nextCursor": null
}

Webhooks

Webhooks push events to an HTTPS URL you register under Settings → Developers → Webhooks. Each endpoint has its own signing secret (whsec_…), shown once at creation, and a list of subscribed events.

Deliveries are POST requests with a JSON envelope ({ id, type, createdAt, apiVersion, data }) and the headers X-AgentCalls-Event, X-AgentCalls-Delivery and X-AgentCalls-Signature. Always verify the signature before trusting the body. Treat events as notifications and fetch the call through the API when you need to be sure; events can arrive out of order and more than once.

The dashboard shows every delivery with its attempts, last status and error, lets you retry a failed delivery, and has a "Send test event" button that posts a signed call.ended sample and shows the status code your endpoint returned.

Events

EventWhendata
call.endedThe call finished and was priced; facts of the call, not yet the summary.`call`, `contact`
call.summary.readyA few seconds later, once summary, outcome, sentiment and structured data are ready.`call`, `contact`, `transcript`
wallet.lowOnce per low-balance period when the wallet drops under the alert threshold.`organizationId`, `walletBalanceCents`, `includedMinutesRemaining`, `thresholdCents`
call.startedReserved for an upcoming release.—
number.activatedReserved for an upcoming release.—

Payload

Headers: X-AgentCalls-Event, X-AgentCalls-Delivery, X-AgentCalls-Signature.

{
  "id": "evt_6a1f0c3b9d2e4f5a6b7c8d9e",
  "type": "call.summary.ready",
  "createdAt": "2026-10-15T14:01:40.000Z",
  "apiVersion": "2026-10-15",
  "data": {
    "call": {
      "id": "68ef3b2c001a9d4e7f10",
      "direction": "inbound",
      "status": "ended",
      "from": "+15005550001",
      "to": "+13145550199",
      "agentId": "6ad0bf49171339b53c8e",
      "agentVersion": 3,
      "contactId": "68ef3a91002b7c3d5e01",
      "startedAt": "2026-10-15T14:00:05.000Z",
      "answeredAt": "2026-10-15T14:00:05.000Z",
      "endedAt": "2026-10-15T14:01:35.000Z",
      "durationSec": 90,
      "billableSec": 90,
      "costCents": 70,
      "endedReason": "customer-ended-call",
      "consentPlayed": true,
      "outcome": "callback_requested",
      "sentiment": "neutral",
      "summary": "Sara asked for a callback tomorrow morning.",
      "structuredData": {
        "intent": "callback",
        "callback_requested": true
      },
      "hasRecording": true,
      "toolCalls": [
        {
          "name": "update_contact",
          "ok": true,
          "at": "2026-10-15T14:00:14.000Z"
        }
      ]
    },
    "contact": {
      "id": "68ef3a91002b7c3d5e01",
      "phone": "+15005550001",
      "name": "Sara Ahmed",
      "email": null,
      "tags": [],
      "attributes": {},
      "callCount": 1,
      "doNotCall": false
    },
    "transcript": [
      {
        "role": "agent",
        "text": "Hi, you've reached Northwind Clinic.",
        "at": 1.2
      },
      {
        "role": "user",
        "text": "Hi, it's Sara, I'd like a callback tomorrow.",
        "at": 5.1
      }
    ]
  }
}

Verifying the signature

X-AgentCalls-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>

  1. Read the raw request body as bytes; do not parse and re-serialize it.
  2. Split the header on ,; take the t= value and every v1= value.
  3. Compute HMAC-SHA256 over the string ${t}.${rawBody} with your endpoint secret (whsec_…, shown once when the endpoint was created) and hex-encode it.
  4. Compare with each v1 using a constant-time comparison; accept if any matches (more than one v1 is present while a secret rotates).
  5. Reject if |now − t| is more than 300 seconds (replay protection).
  6. Respond 2xx within 10 seconds; the body is ignored. Anything else is retried after 1 min, 5 min, 30 min, 2 h and 12 h (six attempts); 404 and 410 are treated as permanent. De-duplicate on the envelope id.
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, rawBody: string, header: string, toleranceSec = 300): boolean {
  const parts = header.split(",").map((p) => p.trim().split("=") as [string, string]);
  const t = Number(parts.find(([k]) => k === "t")?.[1]);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return parts.some(([k, v]) => k === "v1" && v.length === expected.length && timingSafeEqual(Buffer.from(v, "hex"), Buffer.from(expected, "hex")));
}

Errors

Every error response has the same shape: { error: { code, message, details? } }. Match on code, show message to humans.

CodeHTTPMeaning
invalid_request400Malformed JSON, failed validation or a bad query parameter. details lists the issues.
invalid_cursor400The cursor is not one we issued, or the row it pointed at is gone. Restart from the first page.
unauthorized401Missing, malformed, unknown or revoked API key.
forbidden403The key lacks the scope the endpoint needs, or a recording link is invalid/expired.
contact_do_not_call403The destination contact is marked do-not-call.
country_not_allowed403The destination country is outside the organization's allowed outbound countries (Settings → Outbound calling; defaults to your organization's country plus the countries of your numbers). details has country and allowed.
not_found404No such resource in your organization.
payment_required402No included minutes or wallet balance, or the organization is suspended/cancelled. Top up in Billing.
conflict409Uniqueness violated (e.g. contact phone already exists).
agent_not_published409Publish the agent in the dashboard before calling with it.
number_not_active409The number is still provisioning or failed.
number_inbound_only409The number cannot place outbound calls.
concurrency_limit429Every concurrent line of your plan is in use. details has inProgress and limit.
rate_limited429More than 60 requests in a minute with one key. Wait Retry-After seconds.
call_start_failed502The voice provider refused to start the call. details.callId identifies the failed call row.
internal500Unexpected error on our side; safe to retry with the same Idempotency-Key.

OpenAPI

The machine-readable specification (OpenAPI 3.1, generated from the same source as this page) is at https://agentcalls.net/api/v1/openapi.json. Import it into Postman, Insomnia or your code generator.