Kalba.ai Public API — v1

Trigger AI-powered outbound calls, feed leads into campaigns, and stream every call, booking, and opt-out into your own systems.

The API is REST over HTTPS, JSON in and out. It's built for your engineering team: every request is authenticated with a bearer key, rate-limited, and idempotent where it matters, and test-mode keys exercise the entire flow with zero telephony and zero spend.


Contents

  1. Authentication
  2. Quickstart
  3. Test mode
  4. Conventions — IDs, pagination, idempotency, rate limits, errors
  5. Calls
  6. Campaigns & leads
  7. Do-not-call (DNC)
  8. Events
  9. Webhooks
  10. Compliance
  11. Error reference

Authentication

Send your key as a bearer token on every request:

Authorization: Bearer kalba_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  • Keys look like kalba_live_<40 hex> (real calls) or kalba_test_<40 hex> (simulated — see Test mode).
  • The full secret is shown exactly once, when the key is created. We store only a hash; if you lose a key, revoke it and create a new one.
  • Keys in query strings are rejected (401). Use the header.

Create and revoke keys in the dashboard under Settings → API Keys, or ask your Kalba.ai contact to provision one during onboarding.

Verify a key any time:

curl https://kalba.ai/api/v1/ping -H "Authorization: Bearer $KALBA_KEY"
# → {"ok":true,"mode":"live","key_prefix":"kalba_live_a1b2c"}

Quickstart

This runs the full lifecycle with a test key — no phone rings, no money spent. Use a to number ending in 0 to force a booked outcome (see Test mode).

export KALBA="https://kalba.ai/api"
export KEY="kalba_test_..."

# 1. Trigger a call (202 Accepted; the call starts queued)
curl -s -X POST $KALBA/v1/calls \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: quickstart-1" \
  -d '{"to":"+37060000000","metadata":{"crm_deal_id":"D-4411"}}'
# → {"id":"call_2bf3...","object":"call","mode":"test","status":"queued", ... }

# 2. Read it back once it completes (test calls finish within ~2 minutes)
curl -s $KALBA/v1/calls/call_2bf3... -H "Authorization: Bearer $KEY"
# → { ..., "status":"completed", "outcome":"booked", "duration_sec":247,
#         "answered":true, "analysis_status":"ready", "booked":true }

# 3. Pull the transcript and the analysis
curl -s $KALBA/v1/calls/call_2bf3.../transcript -H "Authorization: Bearer $KEY"
curl -s $KALBA/v1/calls/call_2bf3.../analysis   -H "Authorization: Bearer $KEY"

# 4. Or consume everything as an event stream (recovery + polling path)
curl -s "$KALBA/v1/events?types=call.completed,call.analyzed" -H "Authorization: Bearer $KEY"

There are two ways to consume outcomes, and you can use both:

  • Pull — poll GET /v1/events with a cursor. No public receiver required.
  • Push — register a webhook and we POST each event to you, signed.

Test mode

kalba_test_ keys hit identical endpoints with identical validation but never touch telephony. A test call is created queued, then a background sweep advances it queued → in_progress → completed over ~1–2 minutes (the delay is deliberate, so your integration handles real asynchronous state instead of an instant reply).

Completed test calls carry a canned Lithuanian transcript and a synthetic analysis, and emit the full call.initiated → call.completed → call.analyzed event sequence — all tagged "mode":"test". Test data is invisible to dashboard stats and costs.

The outcome is deterministic by the last digit of to, so you can force any path in your tests:

to ends inOutcomeDurationTranscript & analysis
0 1 2booked247 s
3 4 5interested180 s
6 7not_interested95 s
8 9no_answer0 s— (unanswered, never analyzed)

Test-mode webhooks and events are isolated: a test key only ever sees mode:test data, a live key only mode:live.


Conventions

Object IDs

Every object has a prefixed, opaque public ID — never a raw database key:

PrefixObject
call_Call
lead_Lead
cmp_Campaign
evt_Event
we_Webhook endpoint
agt_Agent

Pagination

List endpoints return { "data": [...], "has_more": boolean }, newest first. Page with limit (default 20, max 100) and starting_after=<id of the last item on the previous page>.

curl -s "$KALBA/v1/calls?limit=20&starting_after=call_2bf3..." -H "Authorization: Bearer $KEY"

GET /v1/events is the one exception — it defaults to 100 and returns oldest-first (it's a replay cursor, see Events).

Idempotency

Send an Idempotency-Key header (≤255 chars) on POST /v1/calls and POST /v1/campaigns/{id}/leads. A retry with the same key and body returns the original response plus an Idempotent-Replay: true header — safe to retry on a timeout. The same key with a different body returns 409 idempotency_key_reused. Keys are remembered for 24 hours.

Rate limits, quota & concurrency

Every response carries:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
X-RateLimit-Reset: 1784025060      # unix seconds
X-Request-Id: req_c3a5f55d71f2...  # quote this when contacting support
  • Rate limit — per key, per minute. Defaults: 60 reads, 10 writes. Over the limit → 429 rate_limited with a Retry-After header.
  • Monthly minute quota — optional per-key cap on live call minutes (Europe/Vilnius calendar month). Over → 429 quota_exceeded. Reads are never quota-limited.
  • Concurrency — API calls share your account's concurrent-call slots. When full → 429 concurrency_exhausted with Retry-After: 60.

Errors

Every error is the same shape:

{
  "error": {
    "type": "invalid_request",
    "code": "dnc_blocked",
    "message": "This number is on the do-not-call list",
    "param": "to",
    "request_id": "req_81f3a3a6a39e4cb3957b479cd20d01ff"
  }
}

type maps to the HTTP status class; code is a stable machine string you can switch on (full list in the error reference). We never leak internal or provider error text. Cross-account access always returns 404 not_found — never 403 — so IDs can't be probed for existence.


Calls

POST /v1/calls — trigger an outbound call (write)

{
  "to": "+37060000000",
  "agent_id": "agt_...",
  "variables": { "first_name": "Jonas", "order_id": "A-1122" },
  "metadata":  { "crm_deal_id": "D-4411" }
}
  • to — E.164. Lithuanian (+370…) is first-class; other EU prefixes are accepted and flagged "international": true in the response.
  • agent_id(optional) which AI agent to use; defaults to your account's agent.
  • variables(optional) string→string, merged into the agent's dynamic variables. Max 20 keys, 1 KB total. lead_name and campaign_name are reserved.
  • metadata(optional) opaque correlation data, max 10 keys, 2 KB. Echoed on the Call object and on every event it produces.

Returns 202 with the Call object (status: "queued"). The call runs asynchronously; watch for call.completed / call.analyzed via events or webhooks.

The Call object:

{
  "id": "call_...", "object": "call",
  "mode": "live", "direction": "outbound", "source": "api",
  "to": "+37060000000",
  "campaign_id": null, "lead_id": null, "agent_id": null,
  "status": "completed",          // queued | in_progress | completed | failed
  "outcome": "booked",            // booked | interested | not_interested | callback | voicemail | no_answer | failed | unknown | null
  "duration_sec": 247, "answered": true,
  "interest_score": 82, "booked": true,
  "analysis_status": "ready",     // pending | ready | failed
  "metadata": { "crm_deal_id": "D-4411" },
  "created": "2026-07-13T09:00:00Z", "ended_at": "2026-07-13T09:04:07Z"
}

GET /v1/calls — list calls (read)

Query params: limit, starting_after, status, campaign_id, created_gte, created_lte (ISO 8601). Newest first.

GET /v1/calls/{id} — retrieve one call (read)

GET /v1/calls/{id}/transcript (read)

{ "call_id": "call_...", "language": "lt",
  "turns": [ { "role": "agent", "text": "Laba diena…", "t_sec": 0 }, … ] }

Returns 404 transcript_not_ready until the call is done and the transcript is available.

GET /v1/calls/{id}/analysis (read)

{ "summary": "…", "objections": [ … ], "sentiment": "positive",
  "quality_score": 9, "booked_detected": true, "language": "lt" }

Returns 404 analysis_not_ready until post-call analysis has run (call.analyzed).


Campaigns & leads

Campaigns are created and configured in the dashboard; the API lets you list them and feed leads into their existing dispatch machinery (calling windows, retries, concurrency — all handled for you).

GET /v1/campaigns — pick-list of your campaigns (read)

{ "data": [ { "id": "cmp_...", "object": "campaign", "name": "Vasaros kampanija",
              "status": "active", "is_sandbox": false } ], "has_more": false }

POST /v1/campaigns/{id}/leads — add leads (write)

Send one lead or a batch of up to 500. Each item: phone (required), optional name, company, metadata.

{ "leads": [
    { "phone": "+37069900001", "name": "Jonas Petraitis", "company": "UAB Alfa", "metadata": { "crm_id": "L-1" } },
    { "phone": "+37069900001" },
    { "phone": "123" }
] }

Always returns 200 with a per-item breakdown (partial success is normal):

{ "results": [
    { "index": 0, "status": "created", "lead": { "id": "lead_...", … } },
    { "index": 1, "status": "duplicate", "lead_id": "lead_..." },
    { "index": 2, "status": "invalid_phone" }
  ],
  "created": 1, "duplicates": 1, "blocked": 0 }

Per-item status is one of created, duplicate, dnc_blocked, invalid_phone, error. Deduplication is by normalized phone within the campaign; DNC is checked per item. Created leads enter the normal dispatch lifecycle.

GET /v1/campaigns/{id}/leads — list a campaign's leads (read)

Filter by status (pending | scheduled | called | completed | opted_out | exhausted), paginate with limit / starting_after.

GET /v1/leads/{id} — retrieve one lead (read)

{ "id": "lead_...", "object": "lead", "campaign_id": "cmp_...",
  "phone": "+37069900001", "name": "Jonas Petraitis", "company": "UAB Alfa",
  "status": "pending", "attempts": 0, "last_call_id": null,
  "metadata": { "crm_id": "L-1" }, "created": "2026-07-13T08:00:00Z" }

Do-not-call (DNC)

DNC suppression is platform-wide by design — a listed number is blocked for API calls and dashboard dispatch across the whole account. This is compliance data; treat it as authoritative.

POST /v1/dnc — suppress a number (write)

{ "phone": "+37060000000", "reason": "customer email opt-out" }

Returns 201 {"listed": true} (or 200 if it was already listed — idempotent). Emits a lead.opted_out event.

GET /v1/dnc/{phone} — check a number (read)

URL-encode the + as %2B:

curl -s "$KALBA/v1/dnc/%2B37060000000" -H "Authorization: Bearer $KEY"
# → {"phone":"+37060000000","listed":true,"listed_at":"2026-07-14T15:09:43Z"}

Events

Every fact your account produces — whether it originated from the API, the dashboard, or an inbound call — lands in an immutable, append-only ledger. This is the durability guarantee: if a webhook delivery fails or your receiver is down, nothing is lost — replay from the ledger.

Event types:

call.initiated   call.completed   call.failed   call.analyzed
booking.created  booking.confirmed  booking.canceled  lead.opted_out

Envelope (this is also the exact webhook body):

{ "id": "evt_...", "object": "event", "type": "call.completed",
  "created": "2026-07-13T14:02:11Z", "mode": "live",
  "data": { "call": { …the Call object… } } }

GET /v1/events — replay / poll (read)

# Everything after the last event you processed, oldest-first:
curl -s "$KALBA/v1/events?after=evt_36905073...&types=call.completed" -H "Authorization: Bearer $KEY"
  • after — event ID cursor. Omit it to get the most recent 100 (oldest-first).
  • types — comma-separated filter.
  • limit — default 100, max 100. Response { "data": [...], "has_more": boolean }.

Recommended polling loop: remember the id of the last event you handled, and pass it as after next time. Events are kept ≥ 90 days.


Webhooks

Register HTTPS endpoints and we'll POST each matching event to you as it happens — the first attempt inline (low latency), retries on a backoff. Webhooks are at-least-once; dedupe by event.id.

POST /v1/webhook-endpoints — register (write)

{ "url": "https://erp.example.lt/kalba-hook", "types": ["call.completed", "call.analyzed"] }
  • HTTPS only; hosts that resolve to private/loopback addresses are rejected (checked at registration and at every delivery).
  • types defaults to ["*"] (all).
  • Max 5 endpoints per account per mode; mode is inherited from the creating key.

Returns 201 with the endpoint and a secret (whsec_…) shown exactly once — store it, you'll need it to verify signatures.

GET /v1/webhook-endpoints / DELETE /v1/webhook-endpoints/{id} (read / write)

List (secrets never returned) and delete.

Verifying deliveries

Each POST carries a signature header:

Kalba-Signature: t=<unix seconds>,v1=<hex hmac_sha256(secret, t + "." + rawBody)>

Verify against the raw request body bytes (re-serializing the JSON will change whitespace and break the HMAC), with a ±300 s tolerance and a constant-time compare. Copy-paste Node/TypeScript and Python implementations are in webhook-verification.md.

Delivery, retries & auto-disable

  • Success = any 2xx within 10 seconds. Redirects are not followed.
  • Failures retry on 1 m → 5 m → 30 m → 2 h → 12 h, then the delivery is marked exhausted — but the event stays replayable via GET /v1/events, so you never lose data.
  • An endpoint with ≥ 10 consecutive failures and no success for 3 days is automatically disabled and the account owner is emailed. Fix your receiver and register a new endpoint.

Compliance

  • DNC is enforced on every POST /v1/calls and on every lead added to a campaign — a listed number returns 422 dnc_blocked and is never dialed.
  • Calling hours — outbound calls are only placed Mon–Fri, 08:00–17:00 Europe/Vilnius. Outside the window, POST /v1/calls returns 422 outside_calling_hours with a retry_at timestamp (the next window open). The API does not queue out-of-hours calls; your integration decides when to retry.

Error reference

HTTPtypecode values
400 / 422invalid_requestinvalid_phone, dnc_blocked, outside_calling_hours, reserved_variable, campaign_not_active, no_agent_configured, batch_too_large, validation_error
401authenticationauthentication, key_revoked
403permissionread_only_key
404not_foundnot_found, transcript_not_ready, analysis_not_ready
409conflictidempotency_key_reused
429rate_limitrate_limited, quota_exceeded, concurrency_exhausted
500api_errorinternal_error

Read-only keys (created without write scope) can call every GET endpoint but get 403 read_only_key on any write.


Versioning

The path is versioned (/v1). Additive changes — new fields, new event types — are not breaking; your integration must tolerate unknown fields and event types. Breaking changes ship under a new version prefix. The openapi.yaml is the source of truth and is updated in lockstep with the API.