Docs/API reference

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

  1. Sign in and go to Settings → Developer.
  2. Under API keys, create a key and give it a name.
  3. Copy the key immediately. It starts with ck_live_ and is shown only once — after you close the dialog, only the prefix (for example ck_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

  1. The contact is looked up by phone; if not found, it's created. A brand-new contact created this way is recorded with consent pewc and 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.
  2. Compliance gates run (see rejections below).
  3. 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 dnc or opted_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 are null when the denominator is zero.
  • costPerBookingCents and avgCallSec are null when there's nothing to divide.
  • byHour always has 24 entries (0–23, server timezone); byWeekday always has 7 (0 = Sunday … 6 = Saturday).
  • byDay is a continuous series capped at 90 days; day is a short MM/DD label.
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 (attempts will read 1). 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, poll GET /v1/calls) rather than relying on webhooks alone.

Related