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.
- Direction: Kalba.ai → you. We POST to your server while a call is live.
- Who hosts what: you host one HTTPS base URL; we call fixed paths under it.
- Format: JSON in, JSON out. Every response includes a
spoken_*string the agent reads to the caller. - Companion docs: API reference · Integration guide (consumer finance) · Webhook signature verification
Contents
- How it works
- Request format & authentication
- The identify-before-disclose invariant
- Tool reference — the 5 tools, schemas + examples
- Latency budget, timeouts & retries
- Error semantics & safe fallback
- Sandbox: run a local mock
- 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)>. Thesecrethere is the partner-endpoint signing secret from provisioning (it is a different value from yourwhsec_...webhook secrets — keep both).Kalba-Timestamp— the same unix-seconds value ast, exposed as a plain header for logging/middleware convenience. The signed value is the one insideKalba-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:
| Field | Type | Description |
|---|---|---|
call_id | string | The 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. |
direction | string | "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
200withContent-Type: application/jsonand 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
200with 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_customerhas returned"verified": true. All other data tools require thecustomer_refissued by a successful verification.
Your side of the invariant:
customer_refis proof of verification. Issue it only from a successfulidentify_customer. Make it opaque and unguessable, and expire it (an hour is plenty — it only needs to outlive one phone call).- Reject data requests without a valid
customer_ref. Ifget_account_summary,get_next_paymentorrequest_payment_date_changearrives with a missing, unknown, or expiredcustomer_ref, return404. The agent will apologize and offer a callback — it will not guess. - 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"). - The caller's
phonealone is never sufficient — caller ID can be spoofed. Require at least one knowledge factor (dob,personal_code_last4, orcontract_no) to returnverified: 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
Request — phone 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.
| Field | Type | Required | Description |
|---|---|---|---|
call_id | string | always | Correlation (see envelope). |
direction | string | always | inbound / outbound. |
phone | string | always | E.164, e.g. +37061234567. Auto-attached — never asked from the caller. |
dob | string | optional | Date of birth, YYYY-MM-DD. |
personal_code_last4 | string | optional | Last 4 digits of the national personal code. |
contract_no | string | optional | Loan 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
| Field | Type | Required | Description |
|---|---|---|---|
verified | boolean | always | true only when your identity policy is satisfied. |
customer_ref | string | when verified | Opaque, unguessable, short-lived reference — the key for all other tools. |
spoken_hint | string | always | Read 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
| Field | Type | Required |
|---|---|---|
call_id, direction | string | always |
customer_ref | string | always |
Response
| Field | Type | Required | Description |
|---|---|---|---|
status | string | always | One of active | in_arrears | paid_off | closed. |
balance | object | always | { "amount": number, "currency": "EUR" } — outstanding principal + accrued charges. |
next_payment | object | null | always | { "amount": number, "currency": "EUR", "due_date": "YYYY-MM-DD" }, or null when nothing is due. |
days_overdue | number | when in_arrears | Whole days past due. |
spoken_summary | string | always | Read 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
| Field | Type | Required | Description |
|---|---|---|---|
amount | number | always | Installment amount. |
currency | string | always | ISO 4217, e.g. EUR. |
due_date | string | always | YYYY-MM-DD. |
spoken_summary | string | always | Read 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
| Field | Type | Required | Description |
|---|---|---|---|
call_id, direction | string | always | |
customer_ref | string | always | |
requested_date | string | always | YYYY-MM-DD — the date the caller asked for. |
{
"call_id": "call_2bf3e9c1",
"direction": "inbound",
"customer_ref": "cus_8f31a0d2c77b",
"requested_date": "2026-08-25"
}
Response
| Field | Type | Required | Description |
|---|---|---|---|
result | string | always | accepted | rejected | needs_human. |
new_date | string | when accepted | The effective new due date (may differ from requested — say so in spoken_result). |
fee | object | optional | { "amount": number, "currency": "EUR" } if the change carries a fee. Disclose any fee in spoken_result — the agent relies on your wording. |
spoken_result | string | always | Read 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
| Field | Type | Required | Description |
|---|---|---|---|
call_id, direction | string | always | |
customer_ref | string | optional | Present only if the caller verified. |
phone | string | always | Number to call back (defaults to the caller's number; the caller may dictate another). |
topic | string | always | Free-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
| Field | Type | Required | Description |
|---|---|---|---|
ack | boolean | always | true = logged on your side. |
spoken_result | string | always | Read 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:
| Parameter | Value |
|---|---|
| Target latency | p95 ≤ 3 seconds end-to-end per tool call |
| Hard timeout | 10 seconds — after that the attempt is abandoned |
| Retries | 2 retries on 5xx or network failure (3 attempts total), short backoff, same Kalba-Request-Id |
| Not retried | 4xx 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_changemust be idempotent — key onKalba-Request-Idso 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 response | Our behavior |
|---|---|
200, schema-valid | spoken_* 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 4xx | Failure, no retry. Safe fallback (below). |
5xx / timeout / network error | Retried twice; if all attempts fail → safe fallback. |
200 but schema-invalid JSON | Failure, 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-Signatureverified on every request (raw body, ±300 s tolerance, constant-time compare); unsigned requests get401. -
customer_refopaque, unguessable, expiring; data tools return404without a valid one. - Failed
identify_customerresponses leak nothing (neutralspoken_hint). -
request_payment_date_changeidempotent onKalba-Request-Id; any fee disclosed inspoken_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.failedevents from the ledger. -
spoken_*strings reviewed by a native speaker of the call language.