One pass from API key to verified result, in Python and Java.
End-to-end integration
Every other SDK page is a reference: it tells you what a method does once you know you need it. This page is the opposite — one continuous path from an API key to a verified result, with nothing assumed. Work through it once and you have a working integration; go to the reference pages after, for the parts you want to change.
Java is at 0.4.2; Python is at 0.4.1. Your account manager will confirm the coordinates and access for your environment.
Which path is yours
There are four ways a file reaches us. This page walks Route D — you hold
the file in your own S3 bucket and point us at it — because it is the one most
integrations should start from: one call per document, s3:GetObject on one
key, and no bytes through your process. Steps 1, 2 and 5–7 are identical
whichever route you choose. Step 4 shows the alternatives.
1. Install and authenticate
Python
pip install 9thsense
Java (Maven)
<dependency>
<groupId>in.digio.ninthsense</groupId>
<artifactId>ninthsense-sdk</artifactId>
<version>0.4.2</version>
</dependency>
The PyPI package is 9thsense; the import is ninthsense, because Python module
names cannot start with a digit. In Java the groupId is in.digio.ninthsense
while the package is ai.ninthsense — that mismatch is deliberate and permanent.
Your key encodes its environment: zk_test_… keys only ever see test cases,
zk_live_… only live ones. They are not interchangeable, and a key cannot reach
a case in the other environment — you will get a 404, not a 403, because we
do not confirm that a case exists somewhere you cannot see.
Python
import asyncio
from ninthsense import Client
async def main():
async with Client(api_key="zk_live_...", base_url="https://api.9thsense.ai") as client:
...
asyncio.run(main())
Java
import ai.ninthsense.Client;
try (Client client = new Client.Builder()
.apiKey("zk_live_...")
.baseUrl("https://api.9thsense.ai")
.build()) {
// ...
}
Use the context manager / try-with-resources. Both SDKs hold a connection pool; leaking it leaks sockets.
2. Open a case
A case is one unit of work — one applicant, one video call, one loan file. Documents attach to it, and it produces one verdict.
Python
case = await client.cases.create(
"kyc_verification",
client_reference_id="LOAN-2291", # your own id
)
print(case.case_id) # == case.session_id
Java
CaseInfo case_ = client.cases()
.create("kyc_verification", null, "LOAN-2291")
.join();
System.out.println(case_.sessionId());
Always pass client_reference_id. It makes creation idempotent — repeating
the call with the same reference returns the same case rather than a duplicate
— and it is echoed on every webhook, so you can correlate without storing our
id. If a create response is ever lost in flight, this is what lets you recover:
Python
existing = await client.cases.get_by_client_reference("LOAN-2291")
Java
Optional<CaseInfo> existing = client.cases()
.getByClientReference("LOAN-2291").join();
If your agent declares pipeline profiles, name one here to pick among them —
pipeline_profile="transcribe_only" in Python, a fourth argument to create in
Java. Omit it for the agent's default.
3. Grant read access to your bucket
One-time setup, done once per environment, not per case.
Give us a role that can s3:GetObject on the prefix you will drop files into.
s3:ListBucket is not required — that is the difference from the polling
route, and it is usually what makes this acceptable to a security review.
Then configure it once under Admin → Connectors → Inbound (S3): the bucket, the prefix, and the role. That configuration becomes the default read boundary for step 4.
4. Attach the document
Python
doc = await client.cases.add_document_from_source(
case.case_id,
"s3://your-bucket/inbound/LOAN-2291/pan.jpg",
)
if doc.status == "rejected":
print(doc.error) # a rejected document never counts toward a required type
Java
DocumentInfo doc = client.cases()
.addDocumentFromSource(case_.sessionId(),
"s3://your-bucket/inbound/LOAN-2291/pan.jpg")
.join();
if (doc.rejected()) {
System.out.println(doc.error());
}
With no credential named we use the one on your configured inbound connector and bound the read to that connector's bucket and prefix — so the short call is also the narrow one. Name a credential explicitly only to read outside it.
Call it again for each further document on the same case. Each call attaches a separate document; nothing is deduplicated on the object key, so if you need at-most-once, deduplicate your side.
Rejections happen before any bytes move — we HEAD the object first — so an
oversized or wrong-typed file costs one small request, not a transfer.
Pinning the type. If you already know what the file is, say so — the model
is then shown that type's output schema and returns the agreed field names
instead of inventing them. Pass document_type= in Python, or the extra
argument in Java. It does not force the document through: the classifier still
runs, and a confident disagreement is still rejected or recorded as a mismatch.
If the file is not in S3, replace this step and change nothing else:
Python
# Your app holds the bytes. Negotiates presigned PUT, falls back to multipart.
doc = await client.cases.upload(case.case_id, "pan.jpg")
Java
DocumentInfo doc = client.cases()
.uploadFile(case_.sessionId(), Path.of("pan.jpg"), "pan_card")
.join();
There is also a fully hands-off route where you never call the API at all: drop files in your bucket and we poll it. See §6.8 of the integration contract.
5. Complete, and wait for the verdict
Completing is explicit: it tells us no more documents are coming.
Python
# if_needed=True: some agents auto-complete on their final upload and finish
# before you get here, which answers 409 — the desired end state, not a failure.
await client.cases.complete(case.case_id, if_needed=True)
status = await client.cases.wait(case.case_id, interval=3, timeout=300)
print(status.status) # completed / review / denied / failed
Java
// ifNeeded=true: some agents auto-complete on their final upload and finish
// before you get here, which answers 409 — the desired end state, not a failure.
client.cases().complete(case_.sessionId(), true).join();
CaseStatus status = client.cases().awaitCompletion(
case_.sessionId(), Duration.ofSeconds(3), Duration.ofMinutes(5));
Waiting for the verdict is not waiting for the report
wait / awaitCompletion return as soon as the case reaches a terminal
status. A report is a separate artifact that renders afterwards, and only
some products produce one — so waiting for it on an agent that never renders
one waits forever. Use await_complete (Python) / awaitReport (Java) only
when you specifically need the report.
Polling is fine for a few cases. For production, use the webhook in step 7 and treat polling as the fallback.
6. Read the results
Python
artifacts = await client.cases.outputs(case.case_id)
for a in artifacts:
print(a.type, a.filename)
await client.cases.download_all(case.case_id, to="./results")
Java
OutputsManifest outputs = client.cases().listOutputs(case_.sessionId()).join();
for (ArtifactInfo a : outputs.artifacts()) {
client.cases().downloadArtifact(case_.sessionId(), a, Path.of("results")).join();
}
The manifest is the same set whichever route delivered the input: the verdict, the extracted fields, any transcript, the redacted media, and the report if one was rendered.
If you would rather have results pushed into a bucket you own, configure an
s3_push destination — we write every artifact and then manifest.json last,
so the appearance of manifest.json is your signal that the case is complete.
Trigger off that key, never off the individual artifacts.
7. Receive and verify the webhook
Register once:
Python
await client.admin_webhooks.register(
"https://your-app.example.com/9thsense/webhook",
events=["case.completed"],
)
Java
client.adminWebhooks()
.register("https://your-app.example.com/9thsense/webhook",
List.of("case.completed"))
.join();
Then verify every delivery before trusting it. Never parse an unverified body — the endpoint is public, and the signature is the only thing that makes it ours:
Python
from ninthsense import webhooks
from ninthsense.models import SignatureVerificationError
@app.post("/9thsense/webhook")
async def receive(request):
body = await request.body()
try:
event = webhooks.parse_case_completed(
body, dict(request.headers), secret=WEBHOOK_SECRET)
except SignatureVerificationError:
return Response(status_code=400)
# Your own id, echoed back — correlate without storing ours.
my_id = event.client_reference_id
return Response(status_code=200)
Java
WebhookEvent event = Webhooks.constructEvent(
rawBody, headers, webhookSecret); // throws SignatureVerificationException
The signature is HMAC-SHA256 over "{timestamp}.{body}", sent as
X-Digio-Checksum with X-Digio-Timestamp. Deliveries older than 5 minutes are
rejected, so replaying a captured request does not work. Verify against the
raw bytes — re-serializing the JSON first changes them and the check fails.
Respond 2xx quickly and do the work asynchronously; a slow endpoint is retried,
which means duplicate deliveries. Make your handler idempotent on
client_reference_id.
Bucket-delivery acks use a different signature — and a different verifier
If instead of a registered webhook you configure an outbound connector
binding (the ack that fires alongside an s3_push export), that request is
signed differently: X-9thsense-Signature: sha256=<hex>, HMAC over the body
alone, with no timestamp. The verifiers above will not accept it — they expect
the X-Digio-Checksum scheme. Each SDK ships a separate one for it:
Python
from ninthsense import outbound_webhooks
event = outbound_webhooks.parse_event(
raw_body, dict(request.headers), secret=SECRET)
if event.outputs and event.outputs.s3_export:
print(event.outputs.s3_export.manifest_uri) # written last = complete
Java
CaseCompletedEvent event = OutboundWebhooks.parseEvent(
rawBody, headers, secret);
This scheme has no timestamp, so it carries no replay window. Deliveries are
idempotent by execution_id — key your handler on that and a redelivery is
harmless.
Putting it together
Python
import asyncio
from ninthsense import Client
async def verify_applicant(reference: str, s3_uri: str) -> str:
async with Client(api_key="zk_live_...") as client:
case = await client.cases.create(
"kyc_verification", client_reference_id=reference)
doc = await client.cases.add_document_from_source(case.case_id, s3_uri)
if doc.status == "rejected":
raise RuntimeError(f"document rejected: {doc.error}")
await client.cases.complete(case.case_id)
status = await client.cases.wait(case.case_id, timeout=300)
await client.cases.download_all(case.case_id, to=f"./results/{reference}")
return status.status
print(asyncio.run(verify_applicant(
"LOAN-2291", "s3://your-bucket/inbound/LOAN-2291/pan.jpg")))
Java
try (Client client = new Client.Builder().apiKey("zk_live_...").build()) {
CaseInfo c = client.cases()
.create("kyc_verification", null, "LOAN-2291").join();
DocumentInfo doc = client.cases()
.addDocumentFromSource(c.sessionId(),
"s3://your-bucket/inbound/LOAN-2291/pan.jpg").join();
if (doc.rejected()) {
throw new IllegalStateException("document rejected: " + doc.error());
}
client.cases().complete(c.sessionId()).join();
CaseStatus status = client.cases().awaitCompletion(
c.sessionId(), Duration.ofSeconds(3), Duration.ofMinutes(5));
System.out.println(status.status());
}
Before you go live
- Test in the test environment first.
zk_test_…keys are free of quota and fully isolated; nothing crosses over. - Make the webhook handler idempotent. Retries mean duplicate deliveries.
- Decide retention. By default we keep our copy of your files. If the original stays in your bucket — which it does on Route D — turn on purge after export under Admin → Organization → Retention, and our copy is destroyed once results are delivered. The verdict and the audit record are kept either way.
- Handle
rejecteddocuments. A rejected document never counts toward a required type, so the case will sit incomplete rather than fail loudly. - Do not hardcode our case id anywhere your own reference would do.