Kalba.ai Partner Data Endpoints — in-call tool contract

When your customers talk to your Kalba.ai voice agent, the agent can look up and act on your data mid-conversation: verify the caller's identity, read their account status, quote the next payment, or file a payment-date-change request — all by calling HTTPS endpoints that you host.

This document is the contract for those endpoints. It is the one piece of the integration your engineering team implements on your side; everything else (numbers, agents, webhooks) is configured during account provisioning.


Contents

  1. How it works
  2. Request format & authentication
  3. The identify-before-disclose invariant
  4. Tool reference — the 5 tools, schemas + examples
  5. Latency budget, timeouts & retries
  6. Error semantics & safe fallback
  7. Sandbox: run a local mock
  8. Go-live checklist

How it works

During provisioning you give us:

  • Base URL — HTTPS only, e.g. https://api.yourlender.lt.
  • We give you a signing secret for these calls (shown once — store it like a password).

Mid-call, when the agent needs data, we POST to:

POST {base_url}/kalba/tools/{tool}

where {tool} is one of the five fixed tool names below. Your server answers with a small JSON object; the spoken_* field is read verbatim to the caller, so write it in the language of the call (Lithuanian for LT lines) and keep it short — one to three sentences.

A typical inbound customer-service exchange:

Caller:  "Norėčiau sužinoti, kada mano kita įmoka."
Agent →  POST /kalba/tools/identify_customer   { "phone": "+37061234567", "dob": "1985-03-12" }
You  →   { "verified": true, "customer_ref": "cus_8f31...", "spoken_hint": "Tapatybė patvirtinta." }
Agent →  POST /kalba/tools/get_next_payment    { "customer_ref": "cus_8f31..." }
You  →   { "amount": 58.4, "currency": "EUR", "due_date": "2026-08-15",
           "spoken_summary": "Kita įmoka — 58 eurai 40 centų, mokėtina rugpjūčio 15 dieną." }
Agent:   "Jūsų kita įmoka — 58 eurai 40 centų, mokėtina rugpjūčio 15 dieną."

Request format & authentication

Every request from us carries:

POST /kalba/tools/get_next_payment HTTP/1.1
Host: api.yourlender.lt
Content-Type: application/json
Kalba-Signature: t=1784025060,v1=5f7a...c219
Kalba-Timestamp: 1784025060
Kalba-Request-Id: treq_c3a5f55d71f2...
  • Kalba-Signature — the exact same scheme as our webhook signatures: t=<unix seconds>,v1=<hex hmac_sha256(secret, t + "." + rawBody)>. The secret here is the partner-endpoint signing secret from provisioning (it is a different value from your whsec_... webhook secrets — keep both).
  • Kalba-Timestamp — the same unix-seconds value as t, exposed as a plain header for logging/middleware convenience. The signed value is the one inside Kalba-Signature; verify that one.
  • Kalba-Request-Id — unique per logical tool invocation and stable across retries (see retries). Use it to deduplicate mutating tools and quote it to support.

Verify every request. Reject anything with a missing/invalid signature or a timestamp outside ±300 seconds with 401. The copy-paste verifyKalbaSignature implementations in webhook-verification.md (Node/TypeScript and Python) work unchanged — pass the partner-endpoint signing secret instead of the webhook secret, and — as always — verify against the raw request body bytes, never re-serialized JSON.

// Express — same helper as for webhooks, different secret:
import { verifyKalbaSignature } from "./verify-kalba-signature"

app.post("/kalba/tools/:tool", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyKalbaSignature(
    process.env.KALBA_TOOL_SECRET!,
    req.header("Kalba-Signature") ?? "",
    req.body.toString("utf8")
  )
  if (!ok) return res.status(401).json({ error: "bad signature" })
  const args = JSON.parse(req.body.toString("utf8"))
  // ... dispatch on req.params.tool ...
})

Request body envelope

Every tool request body is a flat JSON object: the tool's input fields plus two correlation fields present on all tools:

FieldTypeDescription
call_idstringThe Kalba call ID (call_...) this request belongs to — the same ID you see in call.* webhook events. Log it; it ties your tool logs to transcripts and analyses.
directionstring"inbound" or "outbound" — how the conversation started.

Unknown extra fields may appear in future (additive versioning) — ignore fields you don't recognize; never error on them.

Response rules

  • 200 with Content-Type: application/json and a body matching the tool's response schema below. We schema-validate every response; an invalid body is treated as a failure (see safe fallback).
  • spoken_* strings are read to the caller verbatim. Language of the call, ≤ ~300 characters, no markup, no PII beyond what the verified caller may hear.
  • Prefer returning 200 with a domain-negative result ("verified": false, "result": "needs_human") over HTTP errors. HTTP errors mean "your system is broken", not "the answer is no".

The identify-before-disclose invariant

This is a hard invariant, enforced on both sides:

The agent discloses nothing account-specific — no balance, no dates, no amounts, not even whether a contract exists — until identify_customer has returned "verified": true. All other data tools require the customer_ref issued by a successful verification.

Your side of the invariant:

  1. customer_ref is proof of verification. Issue it only from a successful identify_customer. Make it opaque and unguessable, and expire it (an hour is plenty — it only needs to outlive one phone call).
  2. Reject data requests without a valid customer_ref. If get_account_summary, get_next_payment or request_payment_date_change arrives with a missing, unknown, or expired customer_ref, return 404. The agent will apologize and offer a callback — it will not guess.
  3. Never leak through spoken_hint. On a failed verification the hint must not reveal whether the phone number matched a real customer ("Nepavyko patvirtinti tapatybės" — not "gimimo data neatitinka").
  4. The caller's phone alone is never sufficient — caller ID can be spoofed. Require at least one knowledge factor (dob, personal_code_last4, or contract_no) to return verified: true.

request_callback is the one tool usable without verification (an unverified caller may still leave a callback request) — that's why its customer_ref is optional.


Tool reference

Fixed tool names — these five, exactly. We call only the tools relevant to the agent's configuration; you must implement every tool your agent has enabled (agreed during provisioning). All examples are consumer-loan-servicing flavored.

1. identify_customer

Verify the caller's identity from identifiers collected in conversation. The gate for everything else.

POST {base_url}/kalba/tools/identify_customer

Requestphone is attached automatically (the caller's number on inbound, the dialed number on outbound); the knowledge factors are whatever the agent has collected so far. Expect any subset — the agent may call this tool more than once per call as it gathers more identifiers.

FieldTypeRequiredDescription
call_idstringalwaysCorrelation (see envelope).
directionstringalwaysinbound / outbound.
phonestringalwaysE.164, e.g. +37061234567. Auto-attached — never asked from the caller.
dobstringoptionalDate of birth, YYYY-MM-DD.
personal_code_last4stringoptionalLast 4 digits of the national personal code.
contract_nostringoptionalLoan contract number as spoken by the caller (normalize tolerantly — spoken digits arrive with spaces/mistakes).
{
  "call_id": "call_2bf3e9c1",
  "direction": "inbound",
  "phone": "+37061234567",
  "dob": "1985-03-12",
  "contract_no": "PB-2024-004411"
}

Response

FieldTypeRequiredDescription
verifiedbooleanalwaystrue only when your identity policy is satisfied.
customer_refstringwhen verifiedOpaque, unguessable, short-lived reference — the key for all other tools.
spoken_hintstringalwaysRead to the caller. On success: confirmation. On failure: a neutral non-leaking prompt for what to try.
{
  "verified": true,
  "customer_ref": "cus_8f31a0d2c77b",
  "spoken_hint": "Ačiū, jūsų tapatybė patvirtinta."
}

Failed attempt:

{
  "verified": false,
  "spoken_hint": "Deja, nepavyko patvirtinti tapatybės. Ar galite pasakyti savo sutarties numerį?"
}

2. get_account_summary

Overall account state — the workhorse of "kokia mano paskolos būklė?".

POST {base_url}/kalba/tools/get_account_summary

Request

FieldTypeRequired
call_id, directionstringalways
customer_refstringalways

Response

FieldTypeRequiredDescription
statusstringalwaysOne of active | in_arrears | paid_off | closed.
balanceobjectalways{ "amount": number, "currency": "EUR" } — outstanding principal + accrued charges.
next_paymentobject | nullalways{ "amount": number, "currency": "EUR", "due_date": "YYYY-MM-DD" }, or null when nothing is due.
days_overduenumberwhen in_arrearsWhole days past due.
spoken_summarystringalwaysRead verbatim to the caller.
{
  "status": "in_arrears",
  "balance": { "amount": 412.9, "currency": "EUR" },
  "next_payment": { "amount": 58.4, "currency": "EUR", "due_date": "2026-08-01" },
  "days_overdue": 12,
  "spoken_summary": "Jūsų sutartis šiuo metu vėluoja 12 dienų. Likutis — 412 eurų 90 centų, artimiausia įmoka 58 eurai 40 centų iki rugpjūčio 1 dienos."
}

3. get_next_payment

Just the next installment — used when the caller asks only "kiek ir kada?".

POST {base_url}/kalba/tools/get_next_payment

Request — same as get_account_summary (customer_ref + envelope).

Response

FieldTypeRequiredDescription
amountnumberalwaysInstallment amount.
currencystringalwaysISO 4217, e.g. EUR.
due_datestringalwaysYYYY-MM-DD.
spoken_summarystringalwaysRead verbatim.
{
  "amount": 58.4,
  "currency": "EUR",
  "due_date": "2026-08-15",
  "spoken_summary": "Kita įmoka — 58 eurai 40 centų, mokėtina iki rugpjūčio 15 dienos."
}

If nothing is due (paid off / closed), return 200 with amount: 0, due_date of the contract end, and a spoken_summary saying so — not an HTTP error.

4. request_payment_date_change

The caller asks to move an installment date. Your system decides — accept, reject, or route to a human. This is the only mutating data tool; make it idempotent on Kalba-Request-Id (retries reuse the same ID).

POST {base_url}/kalba/tools/request_payment_date_change

Request

FieldTypeRequiredDescription
call_id, directionstringalways
customer_refstringalways
requested_datestringalwaysYYYY-MM-DD — the date the caller asked for.
{
  "call_id": "call_2bf3e9c1",
  "direction": "inbound",
  "customer_ref": "cus_8f31a0d2c77b",
  "requested_date": "2026-08-25"
}

Response

FieldTypeRequiredDescription
resultstringalwaysaccepted | rejected | needs_human.
new_datestringwhen acceptedThe effective new due date (may differ from requested — say so in spoken_result).
feeobjectoptional{ "amount": number, "currency": "EUR" } if the change carries a fee. Disclose any fee in spoken_result — the agent relies on your wording.
spoken_resultstringalwaysRead verbatim: the decision, the new date, and any fee.
{
  "result": "accepted",
  "new_date": "2026-08-25",
  "fee": { "amount": 2.0, "currency": "EUR" },
  "spoken_result": "Įmokos data perkelta į rugpjūčio 25 dieną. Taikomas 2 eurų administravimo mokestis, jis bus pridėtas prie kitos įmokos."
}

Use needs_human for anything your policy can't auto-decide — the agent will offer a callback or transfer instead of improvising.

5. request_callback

File a callback request with your team. Works with or without verification — an unverified caller can still be helped later by a human.

POST {base_url}/kalba/tools/request_callback

Request

FieldTypeRequiredDescription
call_id, directionstringalways
customer_refstringoptionalPresent only if the caller verified.
phonestringalwaysNumber to call back (defaults to the caller's number; the caller may dictate another).
topicstringalwaysFree-text, agent-written summary of what the caller needs (in the call language).
{
  "call_id": "call_2bf3e9c1",
  "direction": "inbound",
  "phone": "+37061234567",
  "topic": "Klientas nori aptarti mokėjimo grafiko keitimą, nepavyko patvirtinti tapatybės."
}

Response

FieldTypeRequiredDescription
ackbooleanalwaystrue = logged on your side.
spoken_resultstringalwaysRead verbatim — confirm and set the expectation ("perskambinsime darbo valandomis").
{
  "ack": true,
  "spoken_result": "Užregistravau jūsų užklausą — kolegos perskambins artimiausiu darbo metu."
}

Latency budget, timeouts & retries

The caller is waiting on the line while your endpoint responds. Silence is the worst possible UX in a phone call, so the budget is strict:

ParameterValue
Target latencyp95 ≤ 3 seconds end-to-end per tool call
Hard timeout10 seconds — after that the attempt is abandoned
Retries2 retries on 5xx or network failure (3 attempts total), short backoff, same Kalba-Request-Id
Not retried4xx responses and schema-invalid 200s — these are contract errors, retrying won't help

Design implications:

  • Serve tool responses from a hot path (cache/replica), not a batch system. If your core banking lookup can be slow, put a read-optimized view in front.
  • Because 5xx attempts are retried, request_payment_date_change must be idempotent — key on Kalba-Request-Id so a retry after a half-applied change doesn't move the date twice.
  • The agent covers short waits naturally ("sekundėlę, patikrinsiu"), but anything near the 10 s timeout will feel broken to the caller. Treat 3 s as the real budget; 10 s is the failure boundary, not a target.

Error semantics & safe fallback

What we do with each outcome of a tool call:

Your responseOur behavior
200, schema-validspoken_* is used; conversation continues.
200, domain-negative (verified: false, result: "rejected" / "needs_human")Normal flow — the agent relays your spoken_* wording. Not an error.
401 / 403 / 404 / other 4xxFailure, no retry. Safe fallback (below).
5xx / timeout / network errorRetried twice; if all attempts fail → safe fallback.
200 but schema-invalid JSONFailure, no retry → safe fallback.

Safe fallback: the agent never invents data. On tool failure it tells the caller, in the call language, that the system is temporarily unavailable and offers to arrange a callback — e.g. "Atsiprašau, šiuo metu negaliu pasiekti jūsų sąskaitos duomenų. Ar norėtumėte, kad perskambintume?". The conversation degrades gracefully; nothing account-specific is guessed or fabricated.

Every failed tool invocation additionally emits a tool.failed event into your account's event ledger (and to *-subscribed webhooks) with the tool name, call ID and failure class — alert on it, because each one is a degraded customer conversation:

{
  "id": "evt_...", "object": "event", "type": "tool.failed",
  "created": "2026-08-01T10:14:07Z", "mode": "live",
  "data": { "tool": "get_account_summary", "call_id": "call_2bf3e9c1",
            "reason": "timeout" }
}

Sandbox: run a local mock

The first onboarding step is test mode: with a kalba_test_ key and a sandbox agent session, tool calls are exercised against your endpoint with no telephony and no real customers. Point your provisioned base URL (or the sandbox base URL — you can set a different one for test mode) at a mock like this one and you can develop the whole contract before writing a line of production code:

// mock.js — npm i express && node mock.js
const express = require("express")
const app = express()
app.use(express.json()) // fine for a mock; verify raw-body signatures in production

const CUSTOMER = {
  ref: "cus_mock_0001", dob: "1985-03-12", last4: "6789", contract: "PB-2024-004411",
  status: "active", balance: 412.9, next: { amount: 58.4, due: "2026-08-15" },
}
const eur = (a) => ({ amount: a, currency: "EUR" })

app.post("/kalba/tools/identify_customer", (req, res) => {
  const { dob, personal_code_last4, contract_no } = req.body
  const ok = dob === CUSTOMER.dob || personal_code_last4 === CUSTOMER.last4 ||
             (contract_no || "").replace(/\s/g, "") === CUSTOMER.contract
  res.json(ok
    ? { verified: true, customer_ref: CUSTOMER.ref, spoken_hint: "Ačiū, tapatybė patvirtinta." }
    : { verified: false, spoken_hint: "Nepavyko patvirtinti tapatybės. Gal žinote sutarties numerį?" })
})

const guard = (req, res, next) =>
  req.body.customer_ref === CUSTOMER.ref ? next() : res.status(404).end()

app.post("/kalba/tools/get_account_summary", guard, (_req, res) => res.json({
  status: CUSTOMER.status, balance: eur(CUSTOMER.balance),
  next_payment: { ...eur(CUSTOMER.next.amount), due_date: CUSTOMER.next.due },
  spoken_summary: `Likutis — ${CUSTOMER.balance} eurų, kita įmoka ${CUSTOMER.next.amount} eurų iki rugpjūčio 15 dienos.`,
}))

app.post("/kalba/tools/get_next_payment", guard, (_req, res) => res.json({
  ...eur(CUSTOMER.next.amount), due_date: CUSTOMER.next.due,
  spoken_summary: `Kita įmoka — ${CUSTOMER.next.amount} eurų iki rugpjūčio 15 dienos.`,
}))

app.post("/kalba/tools/request_payment_date_change", guard, (req, res) => res.json({
  result: "accepted", new_date: req.body.requested_date, fee: eur(2),
  spoken_result: `Įmokos data perkelta į ${req.body.requested_date}. Taikomas 2 eurų mokestis.`,
}))

app.post("/kalba/tools/request_callback", (req, res) => res.json({
  ack: true, spoken_result: "Užregistravau — perskambinsime darbo valandomis.",
}))

app.listen(4242, () => console.log("Kalba tool mock on :4242"))

Exercise it directly while developing (this is exactly what our proxy sends, minus the signature):

curl -s localhost:4242/kalba/tools/identify_customer \
  -H "Content-Type: application/json" \
  -d '{"call_id":"call_test","direction":"inbound","phone":"+37061234567","dob":"1985-03-12"}'

For UAT, expose the mock (or your staging system) on a public HTTPS URL — a tunnel like cloudflared/ngrok is fine for testing — and we'll point your sandbox agent at it. Add real signature verification before UAT sign-off: the production proxy signs every request, and your production endpoint must reject unsigned traffic.


Go-live checklist

  • All five tools implemented under {base_url}/kalba/tools/…, HTTPS with a valid certificate.
  • Kalba-Signature verified on every request (raw body, ±300 s tolerance, constant-time compare); unsigned requests get 401.
  • customer_ref opaque, unguessable, expiring; data tools return 404 without a valid one.
  • Failed identify_customer responses leak nothing (neutral spoken_hint).
  • request_payment_date_change idempotent on Kalba-Request-Id; any fee disclosed in spoken_result.
  • p95 ≤ 3 s measured under production-like load; hot-path reads, no batch dependencies.
  • Unknown request fields ignored (additive versioning).
  • Alerting on your own 5xx rate and on tool.failed events from the ledger.
  • spoken_* strings reviewed by a native speaker of the call language.