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/publicAuthentication
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
idand anobjectfield 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
Get the key owner
GET/me
curl https://api.usecallroute.com/v1/public/me \
-H "Authorization: Bearer $CALLROUTE_API_KEY"{
"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
canSend is true when the key's teammate can text from the number.List numbers
GET/numbers
curl https://api.usecallroute.com/v1/public/numbers \
-H "Authorization: Bearer $CALLROUTE_API_KEY"{
"data": [
{
"id": "cmnum2b9x0007",
"object": "number",
"number": "+15125557754",
"name": "Main line",
"type": "local",
"whatsappOnly": false,
"owner": { "id": "cmmem1a2b0001", "name": "Alex Rivera" },
"canSend": true
}
]
}Conversations
List conversations
GET/conversations
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.
curl "https://api.usecallroute.com/v1/public/conversations?status=open&limit=20" \
-H "Authorization: Bearer $CALLROUTE_API_KEY"{
"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}
curl https://api.usecallroute.com/v1/public/conversations/cmcv4h7a2q0003 \
-H "Authorization: Bearer $CALLROUTE_API_KEY"{
"id": "cmcv4h7a2q0003",
"object": "conversation",
"number": "+15125557754",
"participant": "+14155550123",
"status": "open",
"...": "same fields as the list"
}List messages in a conversation
GET/conversations/{id}/messages
Query parameters
limitinteger- How many results to return, 1 to 100. Default 50.
cursorstring- The nextCursor from the previous page.
curl "https://api.usecallroute.com/v1/public/conversations/cmcv4h7a2q0003/messages?limit=50" \
-H "Authorization: Bearer $CALLROUTE_API_KEY"{
"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
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.
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."
}'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}
status, or subscribe to the message webhooks instead.curl https://api.usecallroute.com/v1/public/messages/cmsg8f2k1w0001 \
-H "Authorization: Bearer $CALLROUTE_API_KEY"{
"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}
media field, starting at 0.curl https://api.usecallroute.com/v1/public/messages/cmsg8f2k1w0001/media/0 \
-H "Authorization: Bearer $CALLROUTE_API_KEY" \
-o photo.jpgHTTP/1.1 200 OK
Content-Type: image/jpeg
<file bytes>Calls
List calls
GET/calls
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.
curl "https://api.usecallroute.com/v1/public/calls?filter=missed" \
-H "Authorization: Bearer $CALLROUTE_API_KEY"{
"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}
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.curl https://api.usecallroute.com/v1/public/calls/cmcall5t8r0004 \
-H "Authorization: Bearer $CALLROUTE_API_KEY"{
"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
List contacts
GET/contacts
Query parameters
qstring- Search by name, company, email, or at least 3 digits of a phone number.
cursorstring- The nextCursor from the previous page.
curl "https://api.usecallroute.com/v1/public/contacts?q=miles" \
-H "Authorization: Bearer $CALLROUTE_API_KEY"{
"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}
curl https://api.usecallroute.com/v1/public/contacts/cmct91jd0002 \
-H "Authorization: Bearer $CALLROUTE_API_KEY"{
"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
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.
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"]
}'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}
phones and tags replace the whole list when sent.Body (JSON)
...- Same fields as Create a contact.
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." }'{
"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}
curl -X DELETE https://api.usecallroute.com/v1/public/contacts/cmct91jd0002 \
-H "Authorization: Bearer $CALLROUTE_API_KEY"{ "id": "cmct91jd0002", "deleted": true }Webhook subscriptions
List event types
GET/webhooks/events
curl https://api.usecallroute.com/v1/public/webhooks/events \
-H "Authorization: Bearer $CALLROUTE_API_KEY"{
"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
curl https://api.usecallroute.com/v1/public/webhooks \
-H "Authorization: Bearer $CALLROUTE_API_KEY"{
"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
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.
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"]
}'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}
curl -X DELETE https://api.usecallroute.com/v1/public/webhooks/cmwh3k9p0001 \
-H "Authorization: Bearer $CALLROUTE_API_KEY"{ "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,workspaceIdanddata, 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
idto 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://.
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.
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);
});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 "", 200Retries 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 Goneto turn it off right away. Turn it back on in Settings, Developers.