POST a borrower document, get repayment-risk scores back as JSON. No document content or PII is ever returned or stored.
Both environments run the same code and the same API contract on isolated infrastructure, with separate keys and data. Going live is a base-URL and key swap — nothing else in your integration changes.
| Production | https://tala-api.kita.ai — keys prefixed
kita_prod_. Real borrower documents. 120 submissions/min, 600 result
reads/min. |
| Sandbox | https://sandbox-api.kita.ai — keys prefixed
kita_sbx_. Synthetic or test documents only. 30 submissions/min, 150
result reads/min, plus the fault-injection header below for exercising your error
handling. |
| Warm-up | One instance stays warm, so most requests respond immediately at ~10–15 s per document. Under a scale-up a fresh instance can take ~30 s; a 503 then is normal, retry. |
Every API request needs your key in the X-API-Key header:
curl "BASEURL/v1/model" -H "X-API-Key: your_api_key"
Keys are environment-bound: sandbox keys (prefix kita_sbx_) work only
against https://sandbox-api.kita.ai, and production keys (prefix
kita_prod_) only against https://tala-api.kita.ai.
A key sent to the wrong environment returns 401 with
"reason": "wrong_environment" and a detail naming the base URL
to use — distinct from a plain invalid-key 401, so a misconfigured base URL
is caught on the first call.
curl -X POST "BASEURL/v1/score" \
-H "X-API-Key: your_api_key" \
-F "file=@document.pdf"
{
"status": "ok",
"document": {
"detected_doc_type": "bank_statement", "n_pages": 2,
"triage": { "is_real_document": true, "is_readable": true, "is_blurry": false,
"matches_requested_type": null, "upload_class": "digital_document" }
},
"scores": {
"universal_repayment_propensity": {
"name": "Universal Repayment Propensity Score",
"score": 0.7205,
"percentile": 73.0
},
"bank_cash_flow_stability_index": { "score": 0.752, "percentile": 77.0 }
},
"request_id": "379dadc3"
}
| score | Modelled P(repaid), 0–1. |
| percentile | Rank versus the scoring population, 0–100. |
| detected_doc_type | bank_statement, payslip, utility_bill, id, or other, detected automatically. |
Only the scores that apply to the detected document type. Absent scores are expected, not an error:
| Any document | 5 universal scores (repayment propensity, capture fidelity, authenticity, identity consistency, submission channel) |
| Bank statement | + cash-flow & stability, distress & leakage |
| Payslip | + formal employment, income adequacy |
| Resolvable postal code | + socioeconomic context |
status and score presence, not
exact values; repeat scores of the same file can vary slightly.Successful scores carry document.triage — capture-quality signals for
your own upload UX (retake prompts, wrong-document nudges), alongside the scores:
| is_real_document | The upload looks like a genuine document (not, say, a photo of a desk). |
| is_readable | The content is legible enough to extract. |
| is_blurry | The capture is blurred. |
| matches_requested_type | The document matches the declared_type you submitted (null when you sent none, or when the value was not a recognised type — see below). |
| upload_class | How it was captured: e.g. a digital document, a scan, a photo, a screenshot. |
declared_type is a fixed vocabulary, not free text.
Send one of bank_statement, payslip, utility_bill,
id (aliases such as nomina, estado de cuenta,
ine are accepted). An unrecognised value is ignored — the document is
still scored, and matches_requested_type comes back null. The
value never influences the extraction beyond selecting the hint, so it cannot be used to
steer triage flags or scores.null. Failed extractions carry no
document block at all.Submit returns a job id in ~100 ms; poll for the result.
curl -X POST "BASEURL/v1/score/async" \
-H "X-API-Key: your_api_key" \
-F "file=@document.pdf"
{ "job_id": "a1b2c3d4", "status": "queued" }
# ?wait=25 holds the request until the job finishes (max ~30 s)
curl "BASEURL/v1/score/result/a1b2c3d4?wait=25" \
-H "X-API-Key: your_api_key"
{ "job_id": "a1b2c3d4", "status": "done", "result": { ...same shape as /v1/score... } }
Status flows queued → processing → done (or failed, with a reason).
Instead of uploading bytes, pass a content_url (e.g. a pre-signed S3/GCS
link) and we fetch the document server-side, so it never routes through your servers.
Works on every submission endpoint, as a form or JSON field:
curl -X POST "BASEURL/v1/score/async" \
-H "X-API-Key: your_api_key" \
-F "content_url=https://your-bucket.s3.amazonaws.com/statement.pdf?X-Amz-..."
| URL requirements | https only, publicly resolvable host, document ≤ 30 MB. Up to 3 redirects are followed. |
| When it's fetched | Immediately, at submission; sign pre-signed URLs for at least a few minutes. A failed fetch returns 400 (413 if too large) on the submit call itself. |
| Retention | Same as uploads: the fetched document is processed and discarded, never stored. |
Request bodies are capped (30 MB per document; the platform edge rejects
request bodies over 32 MiB outright, and base64 inflates payloads ~1.34×). For
anything that would push an inline body near ~30 MB, upload the bytes first and
score by reference: POST /v1/uploads mints a signed PUT URL,
you PUT the document straight to storage (no API detour), then pass the
upload_id to any scoring endpoint in place of file /
content_base64 / content_url:
curl -X POST "BASEURL/v1/uploads" \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-d '{"content_type": "application/pdf"}'
{ "upload_id": "9f2c...", "upload_url": "https://storage.googleapis.com/...",
"method": "PUT", "expires_at": "2026-07-27T12:15:00Z", "max_bytes": 31457280,
"headers": { "X-Goog-Content-Length-Range": "0,31457280",
"Content-Type": "application/pdf" } }
curl -X PUT --upload-file statement.pdf \
-H "X-Goog-Content-Length-Range: 0,31457280" \
-H "Content-Type: application/pdf" \
"SIGNED_UPLOAD_URL"
curl -X POST "BASEURL/v1/score/json" \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-d '{"upload_id": "9f2c..."}'
| Where it works | Every scoring endpoint: upload_id on the single-document and async endpoints, upload_ids on /v1/score/application, per-item upload_id in /v1/score/batch. |
| PUT URL expiry | 15 minutes. The uploaded object itself auto-purges after ~1 day; an expired upload_id returns 404. |
| Required headers | Send every header from the response's headers object on the PUT. They are signed into the URL — a PUT that omits X-Goog-Content-Length-Range (or sends different values) is rejected by storage with a 403 signature error. |
| Content-Type | Optional in the mint request; when given, the signed URL pins it — send the same Content-Type header on the PUT (it is echoed back in headers). |
| Isolation | An upload_id is scoped to the API key that minted it; another key sees it as 404. |
| Size | The 30 MB document cap (max_bytes) is signed into the URL, so an oversized PUT fails at upload time; anything that slips through is still rejected when scored (413). |
Pass a webhook_url with an async submission and we POST the
terminal status to it (same JSON the poll returns, plus "event": "score.completed")
when the job finishes or fails:
curl -X POST "BASEURL/v1/score/async" \
-H "X-API-Key: your_api_key" \
-F "file=@document.pdf" \
-F "webhook_url=https://api.yourside.com/kita-callback"
Every delivery is signed so you can verify it came from Kita. The signing key is derived from your API key, so there is no extra secret to manage:
import hashlib, hmac
signing_key = hmac.new(API_KEY.encode(), b"kita-webhook-signing-v1",
hashlib.sha256).hexdigest()
def verify(raw_body: bytes, signature_header: str) -> bool: # X-Kita-Signature
expected = "sha256=" + hmac.new(signing_key.encode(), raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)
webhook_url must be https and answer
2xx within 10 s. Polling /v1/score/result/{job_id} remains
the source of truth; the stored status records webhook_delivered either way.Send an Idempotency-Key header (any string of yours, e.g.
loan-4711-bank-stmt) with an async submission. A retried request (timeout,
network blip, crashed worker on your side) returns the original
job_id instead of scoring the document twice:
curl -X POST "BASEURL/v1/score/async" \
-H "X-API-Key: your_api_key" \
-H "Idempotency-Key: loan-4711-bank-stmt" \
-F "file=@document.pdf"
# retried request → same job_id, plus "idempotent_replay": true
Keys are scoped to your API key and replayable for ~1 day (the async
retention window). In batch submissions, set idempotency_key per item.
One key per document. A key is bound to the bytes it was first used with.
Reusing it for a different document returns
409 {"reason": "idempotency_key_conflict"} naming the original
job_id — it will never return the first document's scores for the second
document. Key per document (loan-4711-bank-stmt,
loan-4711-payslip), not per loan.
One request fans out up to 100 documents, each becoming its own async job that
queues and scores in parallel. Use content_url items to keep the request
body small:
curl -X POST "BASEURL/v1/score/batch" \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://api.yourside.com/kita-callback",
"documents": [
{"content_url": "https://...statement.pdf?...", "idempotency_key": "doc-1"},
{"content_url": "https://...payslip.jpg?...", "idempotency_key": "doc-2"},
{"content_base64": "JVBERi0...", "document_name": "bill.pdf"}
]
}'
{ "n_documents": 3, "n_accepted": 3,
"jobs": [ {"index": 0, "job_id": "...", "status": "queued", ...}, ... ] }
A bad item (unfetchable URL, oversized document) is rejected individually with a
message; the rest proceed. Collect results via the batch-level
webhook_url (fires once per document), or in bulk:
curl "BASEURL/v1/score/results?job_ids=id1,id2,id3" \
-H "X-API-Key: your_api_key"
Up to 12 documents in one request; per-document scores plus an aggregate. Uploads, URLs, or a mix. Documents are scored in parallel, up to 6 at a time: applications of 6 or fewer documents return in about the time of the slowest single document (~10–15 s), and a full 12-document application typically completes in about a minute. A 120 s client timeout covers both; allow more when submitting many long scanned statements at once:
curl -X POST "BASEURL/v1/score/application" \
-H "X-API-Key: your_api_key" \
-F "files=@bank_statement.pdf" \
-F "files=@payslip.jpg" \
-F "content_urls=https://...utility_bill.pdf?..."
Entries in documents come back in submission order and echo the
document_name they were submitted with (multipart filename or URL basename):
{ "n_documents": 3, "n_scored": 3,
"application": { "scores": { ... } },
"documents": [
{ "document_name": "bank_statement.pdf", "status": "ok", "scores": { ... } },
{ "document_name": "payslip.jpg", "status": "ok", "scores": { ... } },
{ "document_name": "utility_bill.pdf", "status": "ok", "scores": { ... } }
] }
The aggregate is best-evidence selection, not an average. Every document is scored on every category that applies to it, and each score internally carries a coverage measure of how much of that category's evidence the document contained. Per category, the aggregate takes the score from the document with the strongest evidence, verbatim:
| Cash-flow & stability | from the bank statement, which holds the transaction evidence. |
| Income adequacy | from the payslip, which holds the income evidence. |
| Authenticity, capture fidelity, … | from whichever document evidenced it best. |
Submit a bank statement and a payslip together and each aggregate score will exactly match one document's individual score. That is the design: a well-evidenced score is never diluted by a document with little to say on that category. Documents that failed extraction are excluded.
To avoid holding an HTTP connection open, or when documents arrive at different
times, submit them individually (via /v1/score/async or one
/v1/score/batch call), then aggregate the finished jobs. Same best-evidence
logic as /v1/score/application; nothing is resubmitted:
curl -X POST "BASEURL/v1/score/application/aggregate" \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-d '{"application_id": "app-4711", "job_ids": ["id1", "id2", "id3"]}'
# a job submitted under a DIFFERENT application_id is excluded from the aggregate
# and reported as {"status": "application_mismatch"} — it can never contaminate
# another applicant's result. Jobs submitted without an application_id are included.
{ "complete": true, "n_scored": 3,
"application": { "scores": { ... } },
"documents": [ ... per-document scores ... ] }
Jobs still queued/processing are reported per document and
excluded from the aggregate, with complete: false. Call again once every job
is terminal.
import httpx
API = "BASEURL"
KEY = "your_api_key"
with open("document.pdf", "rb") as f:
r = httpx.post(f"{API}/v1/score",
headers={"X-API-Key": KEY},
files={"file": ("document.pdf", f)},
timeout=120)
r.raise_for_status()
data = r.json()
if data["status"] == "ok":
for key, s in data["scores"].items():
print(f'{s["name"]}: {s["score"]} (p{s["percentile"]})')
else: # "extraction_failed"
print("unreadable:", data["reason"])
An unreadable document (encrypted PDF, corrupt file, blank image) returns
200, not an error. Handle it explicitly:
{
"status": "extraction_failed",
"reason": "encrypted_pdf",
"message": "encrypted / password-protected PDF",
"scores": null
}
| empty_body | Zero-byte upload. |
| unsupported_mime | Not a PDF, JPG, PNG, or HEIC (detected from content; the filename is ignored). |
| encrypted_pdf | Password-protected PDF. |
| corrupt_pdf | Truncated or structurally corrupt PDF (missing end-of-file trailer, or no pages). |
| blank_document | Blank / contentless image (e.g. a flat white frame). |
| too_many_pages | Over 150 pages. |
| oversized_image | Image over 60 megapixels. |
| model_rejected | The document could not be processed. Not retryable. |
| extraction_error | Extraction failed; safe to retry. |
Every extraction_failed response has the same shape — only
reason differs — so one handler covers them all. To exercise
each reason end-to-end, send the X-Kita-Test-Fault header with
any sync scoring request (/v1/score, /v1/score/json,
/v1/score/application); the document then fails with that
reason instead of being extracted:
curl -X POST "$BASE/v1/score/application" \
-H "X-API-Key: $KEY" \
-H "X-Kita-Test-Fault: model_rejected" \
-F "files=@any_document.pdf"
Append :once (e.g.
X-Kita-Test-Fault: extraction_error:once) to fail only the
first upload of a given document body — resending the identical
document succeeds, so you can test retry logic. An unknown reason returns
400 listing the valid values. The header works only in the
sandbox; production ignores it.
| POST /v1/score | Score one document (multipart file, content_url, or upload_id). Waits for the result. |
| POST /v1/score/json | Same, inline content_base64, content_url, or upload_id. |
| POST /v1/uploads | Mint a signed PUT URL for a large document; score it by upload_id. |
| POST /v1/score/async | Returns a job_id in ~100 ms. Supports webhook_url + Idempotency-Key. Recommended for volume. |
| GET /v1/score/result/{job_id} | Poll the job. ?wait=25 long-polls. |
| POST /v1/score/async/json | Base64/URL variant of async submit. |
| POST /v1/score/batch | Up to 100 documents in one request; one async job per document. |
| GET /v1/score/results | Bulk poll: ?job_ids=a,b,c (no long-poll). |
| POST /v1/score/application | Up to 12 documents, scored in parallel; per-document scores plus aggregate. |
| POST /v1/score/application/aggregate | Aggregate previously scored async jobs by job_ids. |
| GET /v1/model | Model metadata and per-score diagnostics. |
| GET /v1/health | Liveness, no auth required. Use it to verify connectivity. |
Full request and response schemas, with a try-it-live console, in the interactive reference.
| Rate limits | Per API key: production 120 submissions/min and 600 result reads/min; sandbox 30 and 150. These are hard ceilings enforced at the network edge, counted per key across the whole fleet. A submission is one request, so a 12-document application costs one submission. Over the limit you get 429: usually JSON with "reason": "rate_limited" and retry_after_s, but a burst well past the ceiling is rejected at the edge with a short non-JSON body, and a small share of those rejections arrive as a dropped connection rather than a readable response. Treat both a 429 and a connection error during a burst as "back off and retry"; do not require a JSON body to parse. |
| Sustained throughput | Warm capacity is roughly 9,000 documents/hour; larger bursts autoscale within about a minute. Sync scoring is bounded by the 120 s deadline below — use /v1/score/async for sustained volume, where documents are processed on a separate fleet and polled. |
| Synchronous deadline | 120 s for /v1/score, /v1/score/json and /v1/score/application. Past it the request is cancelled and returns 504 with "reason": "deadline_exceeded". Async submissions are not affected. |
| Document size | 30 MB per document; larger returns 413 (very large uploads may get a plain-text 413 from the platform edge). Inline request bodies approaching ~30 MB must use POST /v1/uploads + upload_id instead. |
| Documents per application | 12; more returns 413. Applies to /v1/score/application/aggregate job_ids too. |
| Documents per batch | 100; more returns 413. Use content_url items for large batches (inline base64 hits the request-body ceiling). |
| Webhook delivery | https endpoints only; 3 attempts, 10 s timeout each. Poll remains the source of truth. |
| Idempotency keys | Replayable for ~1 day, scoped to your API key. |
| Formats | PDF, JPG, PNG, HEIC, detected from content (filename ignored). Others return extraction_failed. |
| Pages per document | Accepted up to 150; more returns extraction_failed (too_many_pages). Analysis window: the first 25 pages. Pages 1–10 get full visual analysis and pages 11–25 a targeted scan for late-page risk signals; pages beyond 25 are not read. The reported n_pages is always the document's true page count, and it is itself a scoring feature — so send the complete statement rather than pre-trimming it. Merged multi-month statements are the usual way to exceed 150. |
| Image dimensions | 60 megapixels; larger returns extraction_failed (oversized_image). |
| Async result retention | ~1 day, then the job_id returns 404. |
Errors are JSON with a detail field.
| 400 | Missing file or invalid request — also: an empty document body, content_base64 that is not valid base64, or the same form field sent twice. |
| 401 | Bad or missing X-API-Key. With "reason": "wrong_environment": the key belongs to the other environment — check your base URL. |
| 404 | Unknown or expired job_id / upload_id; resubmit the document. |
| 409 | "reason": "idempotency_key_conflict" — this Idempotency-Key was already used for a different document. Use one key per document. |
| 411 | Multipart upload without a Content-Length header. |
| 413 | Over 30 MB or more than 12 documents. |
| 422 | Schema validation failed; check the reference. |
| 429 | Rate limit exceeded ("reason": "rate_limited", plus retry_after_s in the body). Honour the Retry-After header, then retry. |
| 500 | Transient. Safe to retry (async jobs retry automatically). |
| 504 | "reason": "deadline_exceeded" — the synchronous request passed the 120 s scoring deadline and was cancelled. Resubmit via /v1/score/async. A 504 without that reason is a transient platform edge timeout; safe to retry. |
| 502 | Transient gateway error. Safe to retry; prefer /v1/score/async for heavy loads. |
| 503 | Cold start, model still loading. Retry in a few seconds. |