Skip to content

Developers

CallRoute API

Send texts, read calls with their transcripts and AI summaries, keep contacts in step with your CRM, and get a signed webhook the moment a message, call or voicemail comes in.

Introduction

The CallRoute API is a JSON REST API. It works with the same data you see in the dashboard: your numbers, conversations, texts and WhatsApp messages, calls, voicemail and the shared contact book. Webhooks push new activity to your server, so you do not have to poll.

The API and webhooks are included on the Pro and Scale plans at no extra cost. Texts and calls made through the API are billed exactly like the ones made in the app.

Every path in this reference is relative to this base URL:

https://api.usecallroute.com/v1/public

Authentication

Create a key in the app under Settings, Developers, then Create key. The full key is shown once, so copy it somewhere safe. Send it in the Authorization header on every request:

curl https://api.usecallroute.com/v1/public/me \
  -H "Authorization: Bearer cr_live_..."
  • A key acts as the teammate who made it. It sees what that teammate sees in the app: owners and admins see the whole workspace, other teammates see the conversations and calls on their own numbers. It can only text from numbers assigned to that teammate.
  • Read only keys can make GET requests and nothing else. Use them for reporting and dashboards.
  • Each teammate can have up to 10 keys. Revoke a key in Settings, Developers and it stops working at once.
  • Keep keys on your server. Never put one in a web page, a mobile app or a public code repository.

Requests and responses

  • Send JSON bodies with Content-Type: application/json. Every response is JSON, except attachment downloads.
  • Phone numbers are in international format: +14155550123.
  • Times are ISO 8601 in UTC. Money is in cents, in USD. Enum values are lowercase.
  • Every object has an id and an object field saying what it is. We add new fields over time but never rename or remove one, so ignore fields you do not know.

Pagination

Lists return a page of results in data and a nextCursor. To get the next page, send that value back as cursor. When nextCursor is null you have everything.

curl "https://api.usecallroute.com/v1/public/calls?limit=100&cursor=cmcall5t8r0004" \
  -H "Authorization: Bearer $CALLROUTE_API_KEY"

{ "data": [ ... ], "nextCursor": "cmcall2a7k0001" }

Errors

Errors use the usual HTTP status codes and a body with a message written for people. When several fields are wrong, message is a list.

HTTP/1.1 400 Bad Request

{ "statusCode": 400, "message": "to must be a phone number in international format, like +14155550123." }

Status codes

400Bad Request
Something in the request is missing or wrong, or a rule stopped it (for example the contact replied STOP).
401Unauthorized
The key is missing, not valid, or revoked.
403Forbidden
The key is read only, the plan does not include the API, or the workspace is paused.
404Not Found
The object does not exist or the key cannot see it.
409Conflict
A request with the same Idempotency-Key is still running.
429Too Many Requests
Slow down. See Rate limits.
5xxServer error
Something went wrong on our side. Retry with a short wait.

Rate limits

Each key can make 120 requests per minute. Every response says where you stand in X-RateLimit-Limit and X-RateLimit-Remaining. Over the limit you get a 429 with a Retry-After header in seconds. Need more? Email support@usecallroute.com.

Account

Check which workspace and teammate a key belongs to. A good first call when you set up an integration.

Get the key owner

GET/me

Returns the workspace, the teammate the key acts as, and whether the key is read only.
Request
curl https://api.usecallroute.com/v1/public/me \
  -H "Authorization: Bearer $CALLROUTE_API_KEY"
Response
{
  "workspace": { "id": "cmws0a1b2c0001", "name": "Miles Landscaping", "plan": "pro", "timezone": "America/Chicago" },
  "member": { "id": "cmmem1a2b0001", "name": "Alex Rivera", "email": "alex@example.com", "role": "owner" },
  "key": { "id": "cmkey7d6e0002", "readOnly": false }
}

Numbers

The numbers the key can see. Owners and admins see every number in the workspace; other teammates see the numbers assigned to them. canSend is true when the key's teammate can text from the number.

List numbers

GET/numbers

Every active number the key can see, oldest first. Not paginated.
Request
curl https://api.usecallroute.com/v1/public/numbers \
  -H "Authorization: Bearer $CALLROUTE_API_KEY"
Response
{
  "data": [
    {
      "id": "cmnum2b9x0007",
      "object": "number",
      "number": "+15125557754",
      "name": "Main line",
      "type": "local",
      "whatsappOnly": false,
      "owner": { "id": "cmmem1a2b0001", "name": "Alex Rivera" },
      "canSend": true
    }
  ]
}

Conversations

A conversation is the thread between one of your numbers and one outside phone number. It holds the texts, WhatsApp messages and calls between them.

List conversations

GET/conversations

Most recent activity first.

Query parameters

limitinteger
How many results to return, 1 to 100. Default 50.
cursorstring
The nextCursor from the previous page.
numberIdstring
Only conversations on this number.
statusopen | done
Only open or only done conversations.
participantstring
Only the conversation with this outside phone number, like +14155550123.
Request
curl "https://api.usecallroute.com/v1/public/conversations?status=open&limit=20" \
  -H "Authorization: Bearer $CALLROUTE_API_KEY"
Response
{
  "data": [
    {
      "id": "cmcv4h7a2q0003",
      "object": "conversation",
      "number": "+15125557754",
      "numberId": "cmnum2b9x0007",
      "participant": "+14155550123",
      "contact": { "id": "cmct91jd0002", "name": "Jordan Miles" },
      "status": "open",
      "unread": true,
      "assignee": null,
      "lastActivityAt": "2026-10-06T15:04:12.000Z",
      "createdAt": "2026-09-28T10:12:00.000Z"
    }
  ],
  "nextCursor": null
}

Get a conversation

GET/conversations/{id}

One conversation, in the same shape as the list.
Request
curl https://api.usecallroute.com/v1/public/conversations/cmcv4h7a2q0003 \
  -H "Authorization: Bearer $CALLROUTE_API_KEY"
Response
{
  "id": "cmcv4h7a2q0003",
  "object": "conversation",
  "number": "+15125557754",
  "participant": "+14155550123",
  "status": "open",
  "...": "same fields as the list"
}

List messages in a conversation

GET/conversations/{id}/messages

Texts and WhatsApp messages in the conversation, newest first. Calls are listed under Calls.

Query parameters

limitinteger
How many results to return, 1 to 100. Default 50.
cursorstring
The nextCursor from the previous page.
Request
curl "https://api.usecallroute.com/v1/public/conversations/cmcv4h7a2q0003/messages?limit=50" \
  -H "Authorization: Bearer $CALLROUTE_API_KEY"
Response
{
  "data": [
    {
      "id": "cmsg8f2k1w0001",
      "object": "message",
      "conversationId": "cmcv4h7a2q0003",
      "channel": "sms",
      "direction": "inbound",
      "status": "received",
      "from": "+14155550123",
      "to": "+15125557754",
      "numberId": "cmnum2b9x0007",
      "body": "Hi, can I book for Tuesday at 10?",
      "media": [],
      "errorCode": null,
      "segments": 1,
      "costCents": 0,
      "sentBy": null,
      "contact": { "id": "cmct91jd0002", "name": "Jordan Miles" },
      "createdAt": "2026-10-06T15:04:12.000Z"
    }
  ],
  "nextCursor": "cmsg7c1j0w0009"
}

Messages

Read a single message, send a text or WhatsApp message, and download attachments.

Send a message

POST/messages

Sends a text or WhatsApp message from one of your numbers. Only the teammate a number is assigned to can text from it, so use a key made by that teammate. The same rules as the dashboard apply: contacts who replied STOP are never texted, texting registration and safety checks run first, daily limits count, and international texts are paid from your wallet.

Send an Idempotency-Key header (any unique string, up to 200 characters) to make retries safe. Repeating a request with the same key within 24 hours returns the first result instead of sending again. Failed requests are not remembered, so you can retry them with the same key.

Body (JSON)

fromstringrequired
Your number, as a number id or the number itself, like +15125557754.
tostringrequired
The recipient in international format, like +14155550123.
bodystringrequired
The message text, up to 1,600 characters.
channelsms | whatsapp | auto
Default sms. auto sends on WhatsApp when the contact has messaged your WhatsApp in the last 24 hours, and as a text otherwise.
Request
curl https://api.usecallroute.com/v1/public/messages \
  -H "Authorization: Bearer $CALLROUTE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042-confirmation" \
  -d '{
    "from": "+15125557754",
    "to": "+14155550123",
    "body": "Your appointment is confirmed for Tuesday at 10."
  }'
Response
HTTP/1.1 201 Created

{
  "id": "cmsg9a3b4c0010",
  "object": "message",
  "channel": "sms",
  "direction": "outbound",
  "status": "queued",
  "from": "+15125557754",
  "to": "+14155550123",
  "body": "Your appointment is confirmed for Tuesday at 10.",
  "sentBy": { "id": "cmmem1a2b0001", "name": "Alex Rivera" },
  "...": "same fields as every message"
}

Get a message

GET/messages/{id}

One message. Poll it to follow status, or subscribe to the message webhooks instead.
Request
curl https://api.usecallroute.com/v1/public/messages/cmsg8f2k1w0001 \
  -H "Authorization: Bearer $CALLROUTE_API_KEY"
Response
{
  "id": "cmsg8f2k1w0001",
  "object": "message",
  "conversationId": "cmcv4h7a2q0003",
  "channel": "sms",
  "direction": "inbound",
  "status": "received",
  "from": "+14155550123",
  "to": "+15125557754",
  "numberId": "cmnum2b9x0007",
  "body": "Hi, can I book for Tuesday at 10?",
  "media": [],
  "errorCode": null,
  "segments": 1,
  "costCents": 0,
  "sentBy": null,
  "contact": { "id": "cmct91jd0002", "name": "Jordan Miles" },
  "createdAt": "2026-10-06T15:04:12.000Z"
}

Download an attachment

GET/messages/{id}/media/{index}

The file itself (image, audio, PDF and so on) with its content type. The paths are listed in the message's media field, starting at 0.
Request
curl https://api.usecallroute.com/v1/public/messages/cmsg8f2k1w0001/media/0 \
  -H "Authorization: Bearer $CALLROUTE_API_KEY" \
  -o photo.jpg
Response
HTTP/1.1 200 OK
Content-Type: image/jpeg

<file bytes>

Calls

Calls on numbers the key can see, with the recording transcript, the AI summary and follow ups, and voicemail when there is one.

List calls

GET/calls

Newest first. The list leaves out transcripts; get a single call for its transcript.

Query parameters

limitinteger
How many results to return, 1 to 100. Default 50.
cursorstring
The nextCursor from the previous page.
numberIdstring
Only calls on this number.
filtermissed | voicemail
Only missed incoming calls, or only calls that left a voicemail.
Request
curl "https://api.usecallroute.com/v1/public/calls?filter=missed" \
  -H "Authorization: Bearer $CALLROUTE_API_KEY"
Response
{
  "data": [
    {
      "id": "cmcall5t8r0004",
      "object": "call",
      "conversationId": "cmcv4h7a2q0003",
      "channel": "phone",
      "direction": "inbound",
      "status": "completed",
      "from": "+14155550123",
      "to": "+15125557754",
      "numberId": "cmnum2b9x0007",
      "answeredBy": { "id": "cmmem1a2b0001", "name": "Alex Rivera" },
      "contact": { "id": "cmct91jd0002", "name": "Jordan Miles" },
      "startedAt": "2026-10-06T14:30:02.000Z",
      "answeredAt": "2026-10-06T14:30:09.000Z",
      "endedAt": "2026-10-06T14:36:41.000Z",
      "durationSec": 392,
      "costCents": 0,
      "recorded": true,
      "transcriptStatus": "done",
      "summary": {
        "text": "Jordan wants to move Tuesday's visit to 10 AM and asked for a quote.",
        "followUps": [{ "text": "Send the quote by email", "done": false }]
      },
      "voicemail": null
    }
  ],
  "nextCursor": null
}

Get a call

GET/calls/{id}

One call, plus transcript when the call was recorded and transcribed (null otherwise). Each line says who spoke (team or customer) and when, in seconds from the start.
Request
curl https://api.usecallroute.com/v1/public/calls/cmcall5t8r0004 \
  -H "Authorization: Bearer $CALLROUTE_API_KEY"
Response
{
  "id": "cmcall5t8r0004",
  "object": "call",
  "status": "completed",
  "transcriptStatus": "done",
  "transcript": [
    { "speaker": "customer", "startSec": 1.2, "text": "Hi, this is Jordan." },
    { "speaker": "team", "startSec": 3.8, "text": "Hi Jordan, how can I help?" }
  ],
  "...": "same fields as the list"
}

Contacts

The contact book is shared by the whole workspace, so every key sees and edits the same contacts.

List contacts

GET/contacts

50 contacts per page, sorted by name.

Query parameters

qstring
Search by name, company, email, or at least 3 digits of a phone number.
cursorstring
The nextCursor from the previous page.
Request
curl "https://api.usecallroute.com/v1/public/contacts?q=miles" \
  -H "Authorization: Bearer $CALLROUTE_API_KEY"
Response
{
  "data": [
    {
      "id": "cmct91jd0002",
      "object": "contact",
      "name": "Jordan Miles",
      "company": "Miles Landscaping",
      "email": "jordan@example.com",
      "notes": null,
      "phones": [{ "number": "+14155550123", "label": "mobile" }],
      "tags": ["customer"],
      "createdAt": "2026-09-28T10:12:00.000Z"
    }
  ],
  "nextCursor": null
}

Get a contact

GET/contacts/{id}

One contact.
Request
curl https://api.usecallroute.com/v1/public/contacts/cmct91jd0002 \
  -H "Authorization: Bearer $CALLROUTE_API_KEY"
Response
{
  "id": "cmct91jd0002",
  "object": "contact",
  "name": "Jordan Miles",
  "company": "Miles Landscaping",
  "email": "jordan@example.com",
  "notes": null,
  "phones": [{ "number": "+14155550123", "label": "mobile" }],
  "tags": ["customer"],
  "createdAt": "2026-09-28T10:12:00.000Z"
}

Create a contact

POST/contacts

Adds a contact. It needs a name or at least one phone number; everything else is optional.

Body (JSON)

namestring
Up to 120 characters.
companystring
Up to 120 characters.
emailstring
A valid email address.
notesstring
Up to 5,000 characters.
phonesarray
Up to 10 of { "number": "+14155550123", "label": "mobile" }. The label is optional.
tagsarray of strings
Up to 20 tags, 40 characters each. New tags are created for you.
Request
curl https://api.usecallroute.com/v1/public/contacts \
  -H "Authorization: Bearer $CALLROUTE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Jordan Miles",
    "company": "Miles Landscaping",
    "phones": [{ "number": "+14155550123", "label": "mobile" }],
    "tags": ["customer"]
  }'
Response
HTTP/1.1 201 Created

{
  "id": "cmct91jd0002",
  "object": "contact",
  "name": "Jordan Miles",
  "company": "Miles Landscaping",
  "email": "jordan@example.com",
  "notes": null,
  "phones": [{ "number": "+14155550123", "label": "mobile" }],
  "tags": ["customer"],
  "createdAt": "2026-09-28T10:12:00.000Z"
}

Update a contact

PATCH/contacts/{id}

Send only the fields you want to change. phones and tags replace the whole list when sent.

Body (JSON)

...
Same fields as Create a contact.
Request
curl -X PATCH https://api.usecallroute.com/v1/public/contacts/cmct91jd0002 \
  -H "Authorization: Bearer $CALLROUTE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "notes": "Prefers texts after 5 PM." }'
Response
{
  "id": "cmct91jd0002",
  "object": "contact",
  "name": "Jordan Miles",
  "company": "Miles Landscaping",
  "email": "jordan@example.com",
  "notes": "Prefers texts after 5 PM.",
  "phones": [{ "number": "+14155550123", "label": "mobile" }],
  "tags": ["customer"],
  "createdAt": "2026-09-28T10:12:00.000Z"
}

Delete a contact

DELETE/contacts/{id}

Deletes the contact. Conversations and calls with that phone number stay; they just lose the name.
Request
curl -X DELETE https://api.usecallroute.com/v1/public/contacts/cmct91jd0002 \
  -H "Authorization: Bearer $CALLROUTE_API_KEY"
Response
{ "id": "cmct91jd0002", "deleted": true }

Webhook subscriptions

Subscribe a URL to events from code, the way Zapier and Make do it. Webhooks made this way show in Settings, Developers like any other, and are deleted when the key that made them is revoked. You can also add webhooks by hand in Settings, Developers. See Webhooks for what we send.

List event types

GET/webhooks/events

Every event you can subscribe to, with a short description.
Request
curl https://api.usecallroute.com/v1/public/webhooks/events \
  -H "Authorization: Bearer $CALLROUTE_API_KEY"
Response
{
  "data": [
    { "type": "message.received", "description": "A text or WhatsApp message came in." },
    { "type": "call.missed", "description": "An incoming call was not answered." },
    "..."
  ]
}

List webhooks

GET/webhooks

Owners and admins see every webhook in the workspace; other teammates see their own.
Request
curl https://api.usecallroute.com/v1/public/webhooks \
  -H "Authorization: Bearer $CALLROUTE_API_KEY"
Response
{
  "data": [
    {
      "id": "cmwh3k9p0001",
      "url": "https://example.com/callroute/events",
      "description": "CRM sync",
      "events": ["message.received", "call.completed"],
      "enabled": true,
      "disabledReason": null,
      "failingSince": null,
      "createdBy": { "id": "cmmem1a2b0001", "name": "Alex Rivera" },
      "viaApi": true,
      "createdAt": "2026-10-06T12:00:00.000Z"
    }
  ]
}

Subscribe a URL

POST/webhooks

Starts sending the chosen events to your URL. The response includes secret, which is shown only this once: store it to check signatures. A workspace can have up to 25 webhooks.

Body (JSON)

urlstringrequired
A public https URL, up to 500 characters.
eventsarray of stringsrequired
One or more event types from List event types.
descriptionstring
A note for your team, up to 200 characters.
Request
curl https://api.usecallroute.com/v1/public/webhooks \
  -H "Authorization: Bearer $CALLROUTE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/callroute/events",
    "events": ["message.received", "call.missed"]
  }'
Response
HTTP/1.1 201 Created

{
  "id": "cmwh3k9p0001",
  "url": "https://example.com/callroute/events",
  "events": ["message.received", "call.missed"],
  "enabled": true,
  "secret": "whsec_6c1b0e...",
  "...": "same fields as the list"
}

Unsubscribe

DELETE/webhooks/{id}

Deletes the webhook. Nothing more is sent to its URL.
Request
curl -X DELETE https://api.usecallroute.com/v1/public/webhooks/cmwh3k9p0001 \
  -H "Authorization: Bearer $CALLROUTE_API_KEY"
Response
{ "id": "cmwh3k9p0001", "deleted": true }

How webhooks work

A webhook sends an HTTPS POST to your URL each time something happens. Add one in Settings, Developers with Add webhook (then Send test to try it), or subscribe from code with the webhook subscription endpoints.

  • The body is an event: id, type, createdAt, workspaceId and data, which holds the full object in the same shape the API returns.
  • A webhook gets the events its teammate can see. Owners and admins get events for the whole workspace; contact events go to everyone.
  • Each event is sent once per webhook, but a retry can bring it again. Use the event id to skip one you already handled.
  • Answer with any 2xx status within 10 seconds. Do slow work after you answer. Redirects are not followed.
  • The URL must be public and start with https://.
What your server receives
POST /callroute/events HTTP/1.1
Content-Type: application/json
User-Agent: CallRoute-Webhooks/1.0
CallRoute-Event: message.received
CallRoute-Delivery: cmdl5r2w0001
CallRoute-Signature: t=1791298800,v1=5f0c3a...

{
  "id": "evt_9b2f61c04e7a1d3b55a0c8e2",
  "type": "message.received",
  "createdAt": "2026-10-06T15:04:13.000Z",
  "workspaceId": "cmws0a1b2c0001",
  "data": {
    "id": "cmsg8f2k1w0001",
    "object": "message",
    "body": "Hi, can I book for Tuesday at 10?",
    "...": "the full message, same shape as the API"
  }
}

Event types

Events

message.receivedmessage
A text or WhatsApp message came in.
message.sentmessage
A message you sent left CallRoute.
message.deliveredmessage
The carrier confirmed a message you sent was delivered.
message.failedmessage
A message you sent could not be delivered.
call.completedcall
A call ended after someone answered it.
call.missedcall
An incoming call was not answered.
call.transcribedcall, with transcript
A recorded call has its transcript.
call.summarizedcall, with transcript
A recorded call has its AI summary and follow ups.
voicemail.receivedcall
A caller left a voicemail.
voicemail.transcribedcall
A voicemail has its transcript.
contact.createdcontact
A contact was added.
contact.updatedcontact
A contact was changed.
contact.deleted{ "id" }
A contact was deleted.

You can also get this list from GET /webhooks/events. The test event sent from Settings has the type ping.

Checking signatures

Every webhook has its own secret (it starts with whsec_). Each request carries a CallRoute-Signature header like t=1791298800,v1=5f0c3a..., where t is the send time in Unix seconds and v1 is the hex HMAC SHA-256 of {t}.{raw body} with your secret as the key.

To check it: build the same HMAC from the raw body, compare it to v1 with a constant time compare, and refuse requests where t is more than five minutes old. You can see the secret again, or replace it with Make a new secret, in Settings, Developers.

Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.CALLROUTE_WEBHOOK_SECRET; // whsec_...

// Read the raw body: the signature is over the exact bytes we sent.
app.post('/callroute/events', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('CallRoute-Signature') ?? '';
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const body = req.body.toString('utf8');
  const expected = crypto.createHmac('sha256', SECRET).update(`${parts.t}.${body}`).digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  const valid =
    fresh &&
    typeof parts.v1 === 'string' &&
    parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!valid) return res.status(400).send('bad signature');

  const event = JSON.parse(body);
  // Answer fast, then do the work. Use event.id to skip an event you already handled.
  res.sendStatus(200);
  handle(event);
});
Python (Flask)
import hashlib, hmac, json, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["CALLROUTE_WEBHOOK_SECRET"].encode()  # whsec_...

@app.post("/callroute/events")
def callroute_events():
    header = request.headers.get("CallRoute-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    body = request.get_data(as_text=True)  # the raw body, before any parsing
    expected = hmac.new(SECRET, f"{parts.get('t')}.{body}".encode(), hashlib.sha256).hexdigest()
    fresh = abs(time.time() - float(parts.get("t", 0))) < 300
    if not (fresh and hmac.compare_digest(expected, parts.get("v1", ""))):
        abort(400)
    event = json.loads(body)
    handle(event)
    return "", 200

Retries and turning off

  • If your server does not answer 2xx in time, we try again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours. After that the delivery is marked failed.
  • Every delivery, with its payload and your server's answer, shows in the webhook's delivery log in Settings, Developers for 30 days. Use Send again to repeat one.
  • If every delivery fails for three days in a row, we turn the webhook off and email the teammate who made it. Answer 410 Gone to turn it off right away. Turn it back on in Settings, Developers.