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.
| Scope | Grants |
|---|---|
calls:read | GET /calls, GET /calls/{id} (incl. recording links) |
calls:write | POST /calls |
contacts:read | GET /contacts, GET /contacts/{id} |
contacts:write | POST /contacts, PATCH /contacts/{id}, DELETE /contacts/{id} |
agents:read | GET /agents |
numbers:read | GET /numbers |
agents:write | MCP only: create_agent, update_agent, publish_agent |
numbers:write | MCP only: buy_number, assign_number |
voices:read | MCP only: list_voices |
usage:read | MCP only: get_usage |
webhooks:write | MCP 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
| Method | Path | Scope | Summary |
|---|---|---|---|
| POST | /calls | calls:write | Start an outbound call |
| GET | /calls | calls:read | List calls |
| GET | /calls/{id} | calls:read | Get a call |
| GET | /contacts | contacts:read | List contacts |
| POST | /contacts | contacts:write | Create a contact |
| GET | /contacts/{id} | contacts:read | Get a contact |
| PATCH | /contacts/{id} | contacts:write | Update a contact |
| DELETE | /contacts/{id} | contacts:write | Delete a contact |
| GET | /agents | agents:read | List published agents |
| GET | /numbers | numbers:read | List phone numbers |
/api/v1/callsscope calls:writeStart 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.
| Parameter | In | Required | Description |
|---|---|---|---|
Idempotency-Key | header | no | Optional. 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"}'| Status | Description |
|---|---|
| 201 | Call queued. |
| 200 | Idempotent replay of an earlier request. |
| 400 | Validation failed (invalid_request). |
| 402 | Billing gate: no minutes or balance, or the organization is suspended (payment_required). |
| 403 | Contact is do-not-call (contact_do_not_call) or the destination country is not allowed (country_not_allowed). |
| 404 | Agent or number not found (not_found). |
| 409 | agent_not_published, number_not_active or number_inbound_only. |
| 429 | All concurrent lines busy (concurrency_limit), or API rate limit (rate_limited). Retry-After is set. |
| 502 | The 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"
}
}/api/v1/callsscope calls:readList calls
Newest first. Combine filters freely; since/until compare against startedAt.
| Parameter | In | Required | Description |
|---|---|---|---|
agentId | query | no | Only calls handled by this agent. |
status | query | no | Only calls in this state. |
since | query | no | Started at or after this ISO 8601 instant. |
until | query | no | Started at or before this ISO 8601 instant. |
contact | query | no | A contact id, or an E.164 phone number (resolved to the contact). |
limit | query | no | Page size (1–100, default 50). |
cursor | query | no | nextCursor 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"| Status | Description |
|---|---|
| 200 | A page of calls. |
| 400 | Bad 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"
}/api/v1/calls/{id}scope calls:readGet a call
Everything in the list item plus transcript, summary, structured data, tool calls and a signed recording link that expires after 10 minutes.
| Parameter | In | Required | Description |
|---|---|---|---|
id | path | yes | Call id. |
Example request
curl https://agentcalls.net/api/v1/calls/68ef3b2c001a9d4e7f10 \
-H "Authorization: Bearer $AGENTCALLS_API_KEY"| Status | Description |
|---|---|
| 200 | The call. |
| 404 | Unknown 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"
}
}
}/api/v1/contactsscope contacts:readList contacts
Newest first.
| Parameter | In | Required | Description |
|---|---|---|---|
phone | query | no | Exact E.164 match (at most one result). |
tag | query | no | Contacts carrying this tag. |
doNotCall | query | no | Filter by do-not-call flag. |
limit | query | no | Page size (1–100, default 50). |
cursor | query | no | nextCursor from the previous page. Opaque. |
Example request
curl "https://agentcalls.net/api/v1/contacts?tag=vip" \
-H "Authorization: Bearer $AGENTCALLS_API_KEY"| Status | Description |
|---|---|
| 200 | A 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
}/api/v1/contactsscope contacts:writeCreate 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"]}'| Status | Description |
|---|---|
| 201 | Created. |
| 400 | Validation failed. |
| 409 | A 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"
}
}/api/v1/contacts/{id}scope contacts:readGet a contact
| Parameter | In | Required | Description |
|---|---|---|---|
id | path | yes | Contact id. |
Example request
curl https://agentcalls.net/api/v1/contacts/68ef3a91002b7c3d5e01 \
-H "Authorization: Bearer $AGENTCALLS_API_KEY"| Status | Description |
|---|---|
| 200 | The contact. |
| 404 | Not 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"
}
}/api/v1/contacts/{id}scope contacts:writeUpdate a contact
Partial update. attributes merge into the stored map (empty string clears a key); tags replace the list.
| Parameter | In | Required | Description |
|---|---|---|---|
id | path | yes | Contact 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}'| Status | Description |
|---|---|
| 200 | Updated contact. |
| 400 | Validation failed. |
| 404 | Not found. |
| 409 | The 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"
}
}/api/v1/contacts/{id}scope contacts:writeDelete a contact
Past calls keep their contactId; the agent's memory of the contact is deleted with it.
| Parameter | In | Required | Description |
|---|---|---|---|
id | path | yes | Contact id. |
Example request
curl -X DELETE https://agentcalls.net/api/v1/contacts/68ef3a91002b7c3d5e01 \
-H "Authorization: Bearer $AGENTCALLS_API_KEY"| Status | Description |
|---|---|
| 204 | Deleted. |
| 404 | Not found. |
/api/v1/agentsscope agents:readList 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"| Status | Description |
|---|---|
| 200 | All 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
}/api/v1/numbersscope numbers:readList 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"| Status | Description |
|---|---|
| 200 | All 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
| Event | When | data |
|---|---|---|
call.ended | The call finished and was priced; facts of the call, not yet the summary. | `call`, `contact` |
call.summary.ready | A few seconds later, once summary, outcome, sentiment and structured data are ready. | `call`, `contact`, `transcript` |
wallet.low | Once per low-balance period when the wallet drops under the alert threshold. | `organizationId`, `walletBalanceCents`, `includedMinutesRemaining`, `thresholdCents` |
call.started | Reserved for an upcoming release. | — |
number.activated | Reserved 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>
- Read the raw request body as bytes; do not parse and re-serialize it.
- Split the header on
,; take thet=value and everyv1=value. - Compute HMAC-SHA256 over the string
${t}.${rawBody}with your endpoint secret (whsec_…, shown once when the endpoint was created) and hex-encode it. - Compare with each
v1using a constant-time comparison; accept if any matches (more than onev1is present while a secret rotates). - Reject if |now − t| is more than 300 seconds (replay protection).
- 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.
| Code | HTTP | Meaning |
|---|---|---|
invalid_request | 400 | Malformed JSON, failed validation or a bad query parameter. details lists the issues. |
invalid_cursor | 400 | The cursor is not one we issued, or the row it pointed at is gone. Restart from the first page. |
unauthorized | 401 | Missing, malformed, unknown or revoked API key. |
forbidden | 403 | The key lacks the scope the endpoint needs, or a recording link is invalid/expired. |
contact_do_not_call | 403 | The destination contact is marked do-not-call. |
country_not_allowed | 403 | The destination country is outside the organization's allowed outbound countries (Settings → Outbound calling; defaults to the countries of your numbers). details has country and allowed. |
not_found | 404 | No such resource in your organization. |
payment_required | 402 | No included minutes or wallet balance, or the organization is suspended/cancelled. Top up in Billing. |
conflict | 409 | Uniqueness violated (e.g. contact phone already exists). |
agent_not_published | 409 | Publish the agent in the dashboard before calling with it. |
number_not_active | 409 | The number is still provisioning or failed. |
number_inbound_only | 409 | The number cannot place outbound calls. |
concurrency_limit | 429 | Every concurrent line of your plan is in use. details has inProgress and limit. |
rate_limited | 429 | More than 60 requests in a minute with one key. Wait Retry-After seconds. |
call_start_failed | 502 | The voice provider refused to start the call. details.callId identifies the failed call row. |
internal | 500 | Unexpected 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.