Kita

Scoring API

POST a borrower document, get repayment-risk scores back as JSON. No document content or PII is ever returned or stored.

Base URL Auth X-API-Key

Environments

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.

Productionhttps://tala-api.kita.ai — keys prefixed kita_prod_. Real borrower documents. 120 submissions/min, 600 result reads/min.
Sandboxhttps://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-upOne 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.

Authentication

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.

Quickstart: score one document

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"
}
scoreModelled P(repaid), 0–1.
percentileRank versus the scoring population, 0–100.
detected_doc_typebank_statement, payslip, utility_bill, id, or other, detected automatically.

Which scores come back

Only the scores that apply to the detected document type. Absent scores are expected, not an error:

Any document5 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
Testing tip: assert on status and score presence, not exact values; repeat scores of the same file can vary slightly.

Capture triage flags

Successful scores carry document.triage — capture-quality signals for your own upload UX (retake prompts, wrong-document nudges), alongside the scores:

is_real_documentThe upload looks like a genuine document (not, say, a photo of a desk).
is_readableThe content is legible enough to extract.
is_blurryThe capture is blurred.
matches_requested_typeThe document matches the declared_type you submitted (null when you sent none, or when the value was not a recognised type — see below).
upload_classHow 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.
Caveat: triage flags are model-derived signals, not guarantees — treat them as hints for your capture flow, never as a sole decline reason. A flag the model couldn't assess comes back null. Failed extractions carry no document block at all.

Async scoring: recommended for volume

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).

Submit by URL

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 requirementshttps only, publicly resolvable host, document ≤ 30 MB. Up to 3 redirects are followed.
When it's fetchedImmediately, 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.
RetentionSame as uploads: the fetched document is processed and discarded, never stored.

Large documents: signed-URL uploads

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 worksEvery 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 expiry15 minutes. The uploaded object itself auto-purges after ~1 day; an expired upload_id returns 404.
Required headersSend 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-TypeOptional 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).
IsolationAn upload_id is scoped to the API key that minted it; another key sees it as 404.
SizeThe 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).

Webhooks: skip polling

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)
Delivery semantics: at-least-once, best-effort: up to 3 attempts with backoff, then we stop. 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.

Idempotent retries

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.

Batch scoring: up to 100 documents

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"

Score a whole application

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": { ... } }
  ] }

How the aggregate is calculated

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 & stabilityfrom the bank statement, which holds the transaction evidence.
Income adequacyfrom 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.

Async application scoring

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.

Python example

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"])

When a document can't be read

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_bodyZero-byte upload.
unsupported_mimeNot a PDF, JPG, PNG, or HEIC (detected from content; the filename is ignored).
encrypted_pdfPassword-protected PDF.
corrupt_pdfTruncated or structurally corrupt PDF (missing end-of-file trailer, or no pages).
blank_documentBlank / contentless image (e.g. a flat white frame).
too_many_pagesOver 150 pages.
oversized_imageImage over 60 megapixels.
model_rejectedThe document could not be processed. Not retryable.
extraction_errorExtraction failed; safe to retry.

Testing failure handling (sandbox only)

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.

Endpoints

POST /v1/scoreScore one document (multipart file, content_url, or upload_id). Waits for the result.
POST /v1/score/jsonSame, inline content_base64, content_url, or upload_id.
POST /v1/uploadsMint a signed PUT URL for a large document; score it by upload_id.
POST /v1/score/asyncReturns 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/jsonBase64/URL variant of async submit.
POST /v1/score/batchUp to 100 documents in one request; one async job per document.
GET /v1/score/resultsBulk poll: ?job_ids=a,b,c (no long-poll).
POST /v1/score/applicationUp to 12 documents, scored in parallel; per-document scores plus aggregate.
POST /v1/score/application/aggregateAggregate previously scored async jobs by job_ids.
GET /v1/modelModel metadata and per-score diagnostics.
GET /v1/healthLiveness, no auth required. Use it to verify connectivity.

Full request and response schemas, with a try-it-live console, in the interactive reference.

Limits

Rate limitsPer 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 throughputWarm 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 deadline120 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 size30 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 application12; more returns 413. Applies to /v1/score/application/aggregate job_ids too.
Documents per batch100; more returns 413. Use content_url items for large batches (inline base64 hits the request-body ceiling).
Webhook deliveryhttps endpoints only; 3 attempts, 10 s timeout each. Poll remains the source of truth.
Idempotency keysReplayable for ~1 day, scoped to your API key.
FormatsPDF, JPG, PNG, HEIC, detected from content (filename ignored). Others return extraction_failed.
Pages per documentAccepted 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 dimensions60 megapixels; larger returns extraction_failed (oversized_image).
Async result retention~1 day, then the job_id returns 404.

Errors

Errors are JSON with a detail field.

400Missing file or invalid request — also: an empty document body, content_base64 that is not valid base64, or the same form field sent twice.
401Bad or missing X-API-Key. With "reason": "wrong_environment": the key belongs to the other environment — check your base URL.
404Unknown 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.
411Multipart upload without a Content-Length header.
413Over 30 MB or more than 12 documents.
422Schema validation failed; check the reference.
429Rate limit exceeded ("reason": "rate_limited", plus retry_after_s in the body). Honour the Retry-After header, then retry.
500Transient. 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.
502Transient gateway error. Safe to retry; prefer /v1/score/async for heavy loads.
503Cold start, model still loading. Retry in a few seconds.