API Reference
For developers building against your CallOps account — read-and-write access to contacts, calls, campaigns, analytics, and conversations, plus outbound webhooks.
Your provider may brand this platform under a different name, but the API paths, headers, and behavior below are the same. Wherever you see
CallOps, read it as your provider's product name.
This is the complete public REST API. Everything here is verified against the live routes. If your integration needs something not listed, it isn't available yet — ask your provider.
Before you start
Get an API key
- Sign in and go to Settings → Developer.
- Under API keys, create a key and give it a name.
- Copy the key immediately. It starts with
ck_live_and is shown only once — after you close the dialog, only the prefix (for exampleck_live_ab12…) is ever displayed again. If you lose it, revoke it and make a new one.
Keys are tied to your workspace (org). Every request is automatically scoped to your own data — you can never see another workspace's records. Revoking a key in Settings → Developer takes effect immediately.
Base URL
All endpoints live under /api/v1 on your account's domain. Your exact base URL is shown on the Settings → Developer page next to Base URL (for example https://app.yourprovider.com/api/v1). Everywhere below, BASE means that value.
Authentication
Send your key on every request, using either header:
| Header | Example |
|---|---|
Authorization: Bearer … (recommended) |
Authorization: Bearer ck_live_ab12... |
x-api-key |
x-api-key: ck_live_ab12... |
If the key is missing, malformed, or revoked, you get 401 with:
{ "error": "Missing or invalid API key. Send Authorization: Bearer ck_live_..." }
Error format
Errors return a JSON body with a single error string and an appropriate HTTP status:
{ "error": "Invalid phone number" }
Common statuses across all endpoints: 401 (bad/missing key), 400 (bad query parameters), 422 (invalid request body), plus endpoint-specific codes noted below.
Rate limits
There are no rate limits enforced yet. Be a good citizen — batch where you can and don't hammer the API — but no request will currently be rejected for volume. This may change; don't assume unlimited throughput forever.
Endpoints
GET /v1/me
Returns your workspace identity, plan, and wallet balance. The simplest call to confirm a key works.
Response (200)
{
"org": {
"id": "uuid",
"name": "FireBar Labs",
"slug": "firebar",
"plan": "growth",
"planLabel": "Growth",
"status": "active"
},
"wallet": {
"balanceCents": 12500,
"perMinuteRateCents": 35
}
}
| Field | Meaning |
|---|---|
org.plan |
One of starter, growth, scale. |
org.planLabel |
Human label: Starter, Growth, Scale. |
org.status |
active or suspended. |
wallet.balanceCents |
Prepaid balance in cents. |
wallet.perMinuteRateCents |
Your per-minute call rate in cents. |
curl "$BASE/me" \
-H "Authorization: Bearer ck_live_ab12..."
GET /v1/contacts
Lists contacts, newest first.
Query parameters
| Param | Type | Default | Notes |
|---|---|---|---|
limit |
integer | 50 |
Clamped to a maximum of 200. |
offset |
integer | 0 |
Clamped to a minimum of 0. Use with limit to page. |
Response (200)
{
"data": [
{
"id": "uuid",
"firstName": "Ada",
"lastName": "Lovelace",
"phone": "+14155550123",
"email": "ada@example.com",
"consentStatus": "pewc",
"tags": ["vip"],
"source": "api",
"createdAt": "2026-07-06T18:20:00.000Z"
}
],
"limit": 50,
"offset": 0
}
consentStatus is one of pewc, express, none, dnc, opted_out.
curl "$BASE/contacts?limit=25&offset=0" \
-H "Authorization: Bearer ck_live_ab12..."
POST /v1/contacts
Creates a contact, or matches an existing one by phone number (upsert-by-phone). If a contact with the same normalized phone already exists in your workspace, nothing is changed — the existing record is returned as-is. It does not overwrite the existing contact's fields.
Body
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
phone |
string | yes | — | Any format; normalized to E.164. Rejected with 422 if it can't be parsed. |
firstName |
string | no | null |
Max 80 chars. |
lastName |
string | no | null |
Max 80 chars. |
email |
string | no | null |
Max 200 chars. |
consentStatus |
string | no | none |
One of pewc, express, none, dnc, opted_out. |
consentSource |
string | no | api |
Max 300 chars. Where consent was captured. |
tags |
string[] | no | [] |
Up to 20 tags, each max 40 chars. |
timezone |
string | no | null |
IANA timezone (e.g. America/New_York), max 64 chars. |
Responses
| Status | Meaning | Body |
|---|---|---|
| 201 | New contact created. | { "id": "uuid", "created": true } |
| 200 | A contact with that phone already existed (no change). | { "id": "uuid", "created": false } |
| 422 | Invalid body, or phone couldn't be parsed. | { "error": "…" } |
Check the created flag to know whether you inserted or matched. Newly created contacts fire a contact.created webhook (see below).
curl -X POST "$BASE/contacts" \
-H "Authorization: Bearer ck_live_ab12..." \
-H "Content-Type: application/json" \
-d '{
"phone": "(415) 555-0123",
"firstName": "Ada",
"email": "ada@example.com",
"consentStatus": "express",
"tags": ["vip"]
}'
GET /v1/calls
Lists calls, newest first.
Query parameters
| Param | Type | Default | Notes |
|---|---|---|---|
limit |
integer | 50 |
Clamped to a maximum of 200. |
offset |
integer | 0 |
Clamped to a minimum of 0. |
Response (200)
{
"data": [
{
"id": "uuid",
"direction": "outbound",
"status": "completed",
"outcome": "booked",
"durationSec": 132,
"billedCents": 77,
"summary": "Booked a demo for Thursday.",
"contactId": "uuid",
"agentId": "uuid",
"campaignId": "uuid",
"createdAt": "2026-07-06T18:20:00.000Z"
}
],
"limit": 50,
"offset": 0
}
| Field | Values / meaning |
|---|---|
direction |
inbound or outbound. |
status |
queued, ringing, in_progress, completed, failed, no_answer, voicemail. |
outcome |
booked, interested, callback, not_interested, no_answer, voicemail, opted_out, transferred, other, or null if not yet set. |
durationSec |
Talk time in seconds. |
billedCents |
What this call cost. Calls of 15 seconds or less bill 0. |
campaignId |
null for one-off / API-triggered calls. |
curl "$BASE/calls?limit=50" \
-H "Authorization: Bearer ck_live_ab12..."
POST /v1/calls — trigger a call
Places an immediate outbound call. This is a real dialing action and runs the same compliance and billing gates as any campaign call, so it can be rejected for several reasons — read the status codes carefully.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
agentId |
string (UUID) | yes | An agent in your workspace. |
phone |
string | yes | Any format; normalized to E.164. |
firstName |
string | no | Max 80 chars. Used only if the contact is new. |
lastName |
string | no | Max 80 chars. Used only if the contact is new. |
What happens
- The contact is looked up by phone; if not found, it's created. A brand-new contact created this way is recorded with consent
pewcand a consent source noting the caller attests consent — by triggering the call you are asserting you have prior express written consent to call this number. - Compliance gates run (see rejections below).
- The call is placed and returns immediately while it dials.
Responses
| Status | Meaning | Example body |
|---|---|---|
| 202 | Accepted — the call is dialing. | { "callId": "uuid", "status": "dialing" } |
| 422 | Invalid body, or phone couldn't be parsed. | { "error": "Invalid phone number" } |
| 402 | Payment/balance gate: workspace suspended, or wallet below the 10-minute dialing buffer. | { "error": "Balance 120¢ is under the 350¢ (10-minute) dialing buffer." } |
| 404 | agentId not found in your workspace. |
{ "error": "Agent not found" } |
| 409 | Compliance block (see below). | { "error": "Contact is on the do-not-call list" } |
| 502 | The call couldn't be placed by the voice provider. | { "error": "Could not place call: …" } |
The 409 compliance blocks, in the order they're checked:
- Do-not-call: the number is on your internal DNC list, or the contact's consent is
dncoropted_out→"Contact is on the do-not-call list". - Insufficient consent: the contact's consent doesn't satisfy the required level →
"Contact lacks required consent (<status>)". - Outside the calling window: it's outside 08:00–21:00 in the contact's local time →
"Outside the legal calling window for this contact (<reason>)".
A related 422 also occurs if the contact has no timezone and one can't be inferred from the phone's area code → "Cannot determine contact timezone for calling-window check". To avoid it, set timezone on the contact first (via POST /v1/contacts).
curl -X POST "$BASE/calls" \
-H "Authorization: Bearer ck_live_ab12..." \
-H "Content-Type: application/json" \
-d '{
"agentId": "00000000-0000-0000-0000-000000000000",
"phone": "+14155550123",
"firstName": "Ada"
}'
GET /v1/campaigns
Lists your campaigns with progress counts, newest first. This endpoint takes no parameters and returns all campaigns.
Response (200)
{
"data": [
{
"id": "uuid",
"name": "Q3 Reactivation",
"status": "running",
"contactsTotal": 500,
"contactsDone": 213,
"createdAt": "2026-07-06T18:20:00.000Z"
}
]
}
| Field | Meaning |
|---|---|
status |
draft, running, paused, or completed. |
contactsTotal |
Total contacts loaded into the campaign. |
contactsDone |
Contacts in a terminal state (completed, excluded, max_attempts, or failed). |
curl "$BASE/campaigns" \
-H "Authorization: Bearer ck_live_ab12..."
GET /v1/analytics
Returns a rollup of your call analytics over a date range.
Query parameters
| Param | Type | Default | Notes |
|---|---|---|---|
range |
string | 30d |
One of 7d, 30d, 90d, 365d. Any other value falls back to 30d. |
Response (200) — all figures are wrapped in a top-level data object:
{
"data": {
"totals": {
"calls": 1200,
"connected": 640,
"connectRate": 0.53,
"minutes": 1830,
"spendCents": 64050,
"booked": 88,
"interested": 140,
"conversionRate": 0.1375,
"costPerBookingCents": 728,
"avgCallSec": 172
},
"outcomeFunnel": [{ "outcome": "booked", "count": 88 }],
"byDay": [{ "day": "07/06", "calls": 40, "booked": 3, "spendCents": 2100 }],
"byHour": [{ "hour": 9, "calls": 55 }],
"byWeekday": [{ "weekday": 1, "calls": 210 }],
"agentLeaderboard": [
{
"agentId": "uuid",
"name": "Riley",
"calls": 400,
"booked": 30,
"connectRate": 0.55,
"spendCents": 21000
}
],
"sentiment": [{ "sentiment": "positive", "count": 120 }]
}
}
Notes on the numbers:
connectRate= connected ÷ total calls;conversionRate= booked ÷ connected. Both arenullwhen the denominator is zero.costPerBookingCentsandavgCallSecarenullwhen there's nothing to divide.byHouralways has 24 entries (0–23, server timezone);byWeekdayalways has 7 (0 = Sunday … 6 = Saturday).byDayis a continuous series capped at 90 days;dayis a shortMM/DDlabel.
curl "$BASE/analytics?range=7d" \
-H "Authorization: Bearer ck_live_ab12..."
GET /v1/conversations
Lists chat and SMS conversation threads, newest first, using keyset (cursor) pagination.
Query parameters
| Param | Type | Default | Notes |
|---|---|---|---|
limit |
integer | 50 |
Must be 1–200. |
cursor |
string | — | The nextCursor value from the previous page. Omit for page one. |
Response (200)
{
"data": [
{
"id": "uuid",
"channel": "web",
"contactId": "uuid",
"status": "open",
"outcome": "booked",
"lastMessageAt": "2026-07-06T18:20:00.000Z",
"visitorName": "Ada",
"visitorEmail": "ada@example.com"
}
],
"nextCursor": "eyJ0IjoiMjAy..."
}
| Field | Values |
|---|---|
channel |
web or sms. |
status |
open or closed. |
outcome |
Same set as call outcomes (booked, interested, …), or null. |
contactId / visitorName / visitorEmail |
May be null for anonymous web visitors. |
How the cursor works. nextCursor is an opaque token — base64url-encoded, encoding the last row's lastMessageAt timestamp and id together so that threads sharing the same timestamp are never skipped or duplicated across pages. Treat it as a black box: don't parse, build, or modify it — just pass it straight back. When there are no more pages, nextCursor is null. A malformed cursor returns 400 ({ "error": "Invalid cursor" }).
Paging loop:
# page one
curl "$BASE/conversations?limit=50" \
-H "Authorization: Bearer ck_live_ab12..."
# next page — pass the nextCursor you received
curl "$BASE/conversations?limit=50&cursor=eyJ0IjoiMjAy..." \
-H "Authorization: Bearer ck_live_ab12..."
GET /v1/webhook-deliveries
Read-only view of your outbound webhook delivery log, newest first. Returns up to the 100 most recent attempts. Useful for debugging why a webhook didn't reach your endpoint.
Query parameters
| Param | Type | Notes |
|---|---|---|
event |
string | Filter to one event type (e.g. call.booked), max 120 chars. |
status |
string | ok or failed. Filters on whether the delivery succeeded. |
Response (200)
{
"data": [
{
"id": "uuid",
"endpointId": "uuid",
"event": "call.booked",
"statusCode": 200,
"ok": true,
"attempts": 1,
"responseBody": "OK",
"createdAt": "2026-07-06T18:20:00.000Z"
}
]
}
| Field | Meaning |
|---|---|
statusCode |
HTTP status your endpoint returned, or null if the request never completed (timeout / connection error). |
ok |
true if your endpoint returned a 2xx. |
attempts |
Delivery attempt count (see retry behavior below). |
responseBody |
First 500 chars of your endpoint's response, or the error message on failure. |
An invalid status value returns 400.
curl "$BASE/webhook-deliveries?event=call.booked&status=failed" \
-H "Authorization: Bearer ck_live_ab12..."
Outbound webhooks
Instead of polling, you can have CallOps push events to your server. Add an endpoint URL and subscribe it to event types in Settings → Developer under Outbound webhooks. Each endpoint gets its own signing secret, shown on that page.
Event types
| Event | Fires when |
|---|---|
call.completed |
A call finishes. |
call.booked |
A call results in a booked meeting. |
call.opted_out |
A contact opts out during a call. |
contact.created |
A contact is created (including via POST /v1/contacts). |
campaign.completed |
A campaign finishes. |
lead.received |
A new inbound lead arrives (speed-to-lead). |
wallet.low_balance |
Your wallet balance drops low. |
Payload envelope
Every delivery is a POST with a JSON body in this envelope. The data object's fields vary by event type.
{
"id": "evt_9c1f...",
"type": "call.booked",
"createdAt": "2026-07-06T18:20:00.000Z",
"data": { }
}
id is a stable, unique event id (prefixed evt_) — use it to deduplicate on your side, since the same event id will be reused if a delivery is retried.
Request headers
| Header | Value |
|---|---|
content-type |
application/json |
x-callops-event |
The event type (e.g. call.booked). |
x-callops-signature |
HMAC-SHA256 of the raw request body, hex-encoded, keyed with your endpoint's signing secret. |
Verifying the signature (Node.js)
Compute the HMAC over the exact raw body bytes and compare to the header. Always compare with a constant-time function.
const crypto = require("node:crypto");
function verify(rawBody, signatureHeader, signingSecret) {
const expected = crypto
.createHmac("sha256", signingSecret)
.update(rawBody) // the raw request body string, before JSON.parse
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express example — capture the raw body:
// app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
app.post("/webhooks/callops", (req, res) => {
const ok = verify(req.rawBody, req.get("x-callops-signature"), process.env.SIGNING_SECRET);
if (!ok) return res.status(401).end();
const event = JSON.parse(req.rawBody);
// handle event.type / event.data ...
res.status(200).end();
});
The signature is computed over the serialized JSON body exactly as sent, so verify against the raw bytes — not a re-serialized copy of the parsed object.
Delivery, retries, and the log
- Deliveries go only to endpoints that are enabled and subscribed to that event type.
- Each attempt has an 8-second timeout. If your endpoint is slow or unreachable, that attempt is recorded as failed with the error message in
responseBody. - Every attempt is written to the delivery log with its status code, success flag, attempt count, and (truncated) response body — visible in Settings → Developer and via
GET /v1/webhook-deliveries. - Automatic retries are not currently performed — each event is delivered once (
attemptswill read1). Return a 2xx quickly to acknowledge. Because delivery isn't guaranteed and isn't retried, treat webhooks as best-effort and reconcile important state with the REST API (for example, pollGET /v1/calls) rather than relying on webhooks alone.
Related
- Getting started — first login, the workspace tour, and the 15-minute quickstart.
- Connecting your tools — the no-code side of outbound webhooks, plus Slack/Teams/CRM integrations.
- Contacts & staying legal — consent levels the API's
consentStatusfield maps to, and the DNC/opt-out rules that gatePOST /v1/calls. - Running campaigns — how the campaigns this API lists are built and launched.
- FAQ & troubleshooting — the top API question (401 causes) and key rotation.