API guide
This guide describes how to integrate Kirnova assessments into your own product: the routes you can call, the fields you send and receive, the events Kirnova emits, the error codes it returns, and the limits that apply. You need an API key, a server that can keep a secret and, for the embedded frame, a web page that can host an iframe.
Start with Choose an integration. For help, contact team@kirnova.com with the session identifier, the approximate time, the environment and the raw response body.
Choose an integration#
Kirnova administers assessments and returns a reading on five dimensions: Trust, Influence, Grit, Execution and Resilience. There are two ways to deliver an assessment.
Embedded frame. You request a short-lived launch link on your server and load it in an iframe. Kirnova renders the whole sitting, including questions, saving, accessibility, mobile layout and completion. You build the iframe and a completion listener. Most partners should choose this.
Machine-driven API. You create a session, receive the questions for one page as JSON, render them in your own interface, post the answers, and receive the next page. You build the entire respondent experience, including accessibility and mobile support.
Scoring always happens on Kirnova servers. No request contains a score, and a client cannot choose the result its answers produce.
Available assessments#
| Assessment | Embedded frame | Machine-driven API | Who may commission it |
|---|---|---|---|
| TIGER Core | Yes | Yes | A decision-maker: an employer, recruiter or organisation |
| TIGER Student | Yes | Yes | An education institution or programme |
| Kirnova Calling (three lengths) | Yes | Yes | A partner, for career exploration only |
| Kirnova DISC | Yes | Yes | A decision-maker or any paying party |
| Kirnova HEXACO | Yes | Yes | A decision-maker or any paying party |
| Kirnova ORVIS | Yes | Yes | A partner. An interests reading is never a signal for selecting between people |
| Kirnova Cognitive | Yes, while Kirnova has it switched on | Yes, while Kirnova has it switched on | A decision-maker or any paying party |
| TIGER Light | Never | Never | Only the person themselves |
TIGER Light is an assessment a person starts for themselves to understand themselves. It cannot be delivered through a partner integration, and a request for it is refused.
Kirnova Calling is licensed for career exploration only. You must not present it to a person, or use it yourself, as an input to an employment decision. Its payload includes licence attribution and a non-endorsement notice that you must render (see Kirnova Calling payload).
Assessment identifiers on the wire:
| Assessment | Identifier |
|---|---|
| TIGER Core | tiger-core |
| TIGER Student | tiger-student |
| Kirnova Calling | kirnova-calling-saga, kirnova-calling-full, kirnova-calling-core (longest to shortest) |
| Kirnova DISC | kirnova-disc |
| Kirnova HEXACO | kirnova-hexaco |
| Kirnova ORVIS | kirnova-orvis |
| Kirnova Cognitive | kirnova-cognitive |
Entitlements#
Kirnova configures which assessments are available on your account. You cannot change this yourself. An assessment can be enabled for your account and still not be servable on the surface you call. In either case Kirnova refuses the request with a specific TEST_* code (see Errors). It never substitutes a different assessment.
An account whose assessment list has not been configured can start nothing. Every request to start a sitting is refused with 403 TEST_NOT_CONFIGURED. Tell Kirnova which assessments you need before you write code.
A previous behavioural assessment is retired. New sittings cannot be started, and a request for it returns 403 TEST_RETIRED. Existing results remain readable (see Retired assessment payload).
Environments#
| Environment | API host | Frame host | Use |
|---|---|---|---|
| Staging | https://stage.kirnova.com |
https://stage.kirnova.com |
Building and testing |
| Production | https://api.kirnova.com |
https://embed.kirnova.com |
Real respondents |
Do not construct a frame address yourself. The launch call returns it as embed_url; use it as given.
Staging is a separate copy of the service:
- It does not hold real customer data. Use staff or test email addresses, not real candidates. A staging sitting still creates an account and stores a result on staging.
- Keys do not cross environments. Staging and production use separate databases and different signing secrets. A staging key does not work on production, and a launch link made on one is refused on the other.
- Your webhook address, signing secret and enabled assessments are set per environment. Tell Kirnova your staging webhook address and which assessments you want on staging. Staging webhooks follow the same signature rules.
- Rate limits are the same as production, and usage is counted per environment.
Important: the key issued for staging looks like a production key (bi_live_sk_...) but works only on staging. Use the host, not the key prefix, to tell environments apart, and keep the host and key together in one configuration setting.
When your staging integration works, request a production key, then change the API host, frame host, key and webhook secret together.
The interactive API reference is at https://sandbox.kirnova.com/developers/api and the OpenAPI file is at https://sandbox.kirnova.com/developers/openapi.json. Integrations that already send API calls to sandbox.kirnova.com continue to work.
Authentication#
Send your API key as a bearer token on every call to /api/v1/*:
Authorization: Bearer bi_live_sk_XXXXXXXXXXXXXXXXXXXXXXXX
Content-Type: application/json
The bi_ prefix carries no meaning. Do not parse it.
- Display. The secret is shown once, when the key is created. Kirnova stores only a fingerprint, so a lost key is revoked and replaced.
- Key types. A read-only key can retrieve results and usage. It cannot mint a launch token or create a session; those calls return
403 FORBIDDEN. - Domain restriction. A key can be restricted to your domains. When configured, Kirnova checks the
OriginorRefererheader when the frame loads and on every page of the jobs board. Matching is by host suffix, socareers.example.commatches a rule forexample.com. An empty list means unrestricted; a non-empty list with no origin header is a refusal. The check does not apply to the launch-token mint or the machine-driven session route, because server-to-server calls carry no origin. - Keep the key on your server. The key can mint sittings and read every result on your account. The only credential that belongs in a browser is the launch token, which lives 5 minutes and is bound to one respondent and one assessment.
Only the routes documented here are part of the published interface. Other routes on Kirnova hosts are internal and can change without notice.
Embedded frame#
The flow has three steps: mint a launch token on your server, load the frame, and handle completion.
Mint a launch token#
POST /api/v1/embed/launch-tokens
Authorization: Bearer bi_live_sk_XXXXXXXXXXXXXXXXXXXXXXXX
Content-Type: application/json
| Field | Type | Required | Description |
|---|---|---|---|
respondent_name |
string, 1+ characters | Yes | The person's name. Trimmed before use |
respondent_email |
string, valid email | Yes | The identity Kirnova keys the person on. Trimmed and lower-cased |
respondent_phone |
string | No | Stored as sent, without format validation |
test |
string | No | The assessment identifier, for example tiger-core. Omit it only if your account has a default; otherwise the call returns TEST_REQUIRED |
external_respondent_id |
string | No | Your own identifier for the person, echoed back everywhere. Use it to reconcile without matching on email |
metadata |
object | No | Any JSON object, stored and returned unchanged in results, webhooks and listings |
callback_url |
string, valid URL | No | Recorded with the sitting for your reference |
embed_options.hide_results |
boolean | No | Hides the result screen from the respondent at the end of the sitting |
embed_options.theme |
"light" or "dark" |
No | Accepted but has no effect. The sitting theme comes from the branding stored on your account |
embed_options.disable_pdf |
boolean | No | Has no effect on any assessment that can be started today. No download is offered at the end of a sitting |
Other fields are ignored.
curl -X POST https://api.kirnova.com/api/v1/embed/launch-tokens \
-H "Authorization: Bearer $KIRNOVA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"respondent_name": "Priya Raman",
"respondent_email": "priya.raman@example.com",
"external_respondent_id": "cand_88214",
"test": "tiger-core",
"metadata": { "requisition_id": "REQ-4471", "role": "Operations Lead" }
}'
Response 201:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_at": "2026-08-19T09:35:12.000Z",
"embed_url": "https://embed.kirnova.com/embed/v1/launch?token=eyJhbGciOi...&theme=light",
"tenant_id": "tn_7f2a91c4",
"test": "tiger-core"
}
Use embed_url as given. Do not rebuild it from token. The test field is the resolved assessment identifier after your account default is applied; log it against your record of the sitting.
Note: a body that fails validation returns 422 VALIDATION_ERROR with the message "respondent_name and respondent_email are required", whichever field failed. Validate your payload before sending it.
Token lifetime and reuse#
A launch token lives 5 minutes. Mint it when the respondent is about to start, not when you build the page. A token is also refused if its issue time is more than a minute in the future or if the window between issue and expiry exceeds fifteen minutes.
Presenting the same token a second time resumes the same sitting; it does not fail. If the respondent reloads the frame, Kirnova returns the same session with the same questions and the answers so far. A second use fails only when the sitting can no longer be resumed: it was abandoned, its window has lapsed, or the token belongs to a different account.
The assessment identifier is signed into the token. Your assessment list is checked again when a new sitting launches, so a change to your entitlement between minting and launching refuses the new sitting. A resume is not rechecked.
Load the frame#
<iframe
id="kirnova-frame"
src="https://embed.kirnova.com/embed/v1/launch?token=...&theme=light"
width="100%"
height="900"
style="border:0"
allow="clipboard-write"
title="Assessment"
></iframe>
TIGER Core and TIGER Student sittings send a height message when content height changes, so you can resize the frame from it. Kirnova Calling sends no messages; use a fixed height of at least 900 pixels or a full-viewport frame.
Every sitting opens on a card headed "Before you begin" with six points and one button, "I understand and acknowledge". No question is shown until the person presses it. The six points are:
- The results may not appear due to technical difficulties (this is rare but it can happen).
- A smartphone may not give you the best presentation of the results. We recommend using a computer or a tablet.
- Your results may not match your own perceptions.
- Your results are not a clinical assessment.
- We cannot discuss your results with you.
- Your responses may be aggregated with others' and used for research.
The card carries no Kirnova name or link. Kirnova records when it was accepted against the session, and does not show it again when the same session reopens. The acknowledgement does not replace any notice or consent you must give the person yourself. The machine-driven API has no such card.
Errors inside the frame render as an HTML message, not JSON, and carry no code. Detect problems when you mint, where you receive a code, and mint again if a frame shows an error.
To send the respondent somewhere when they finish, add redirect_url to the frame address. It must use https; other values are dropped.
Display options and branding#
Set hide_results and disable_pdf in embed_options when you mint the token. They are then signed into the token, and a respondent cannot remove them by editing the frame address. The same options can be set on the frame address, but an option set only there can be removed by the respondent, and an address option can add to the token but never take away from it.
Two of your branding settings reach the sitting on TIGER Core, TIGER Student, Kirnova Calling, Kirnova DISC and Kirnova HEXACO:
- Light, dark, or follow the device, set on your account.
- Your primary brand colour. Kirnova adjusts its lightness where needed to stay readable (at least 3:1 for fills and edges, 4.5:1 for text) and keeps the hue. Focus outlines and the high, moderate and low colours are not re-coloured.
Nothing else reaches these sittings. Your logo, secondary colour, font, button shape and welcome or completion messages have no effect. The assessment's own name appears in the sitting, and the generated document uses Kirnova's presentation. Do not promise your customers a fully re-skinned experience.
Browser messages#
The frame posts messages to the hosting page. Which messages you receive depends on the assessment.
TIGER Core and TIGER Student#
| Message | When | Shape |
|---|---|---|
BI_RESIZE |
The content height changes | { "type": "BI_RESIZE", "data": { "height": 1184 } } |
BI_ASSESSMENT_COMPLETE |
The respondent finishes | { "type": "BI_ASSESSMENT_COMPLETE", "data": { "session_id": "<uuid>" } } |
kirnova:assessment:completed |
The same moment | { "type": "kirnova:assessment:completed", "sessionId": "<uuid>" } |
Two completion messages are sent for one finish. Listen for one and act on it once. Neither carries a result, only the session identifier. There are no started, progress or abandonment messages.
Kirnova Calling, Kirnova DISC, Kirnova HEXACO, Kirnova ORVIS and Kirnova Cognitive post no messages. Learn the outcome from the signed webhook or by requesting the result.
Listener example#
const KIRNOVA_FRAME_ORIGIN = "https://embed.kirnova.com"; // On staging, use the origin of your embed_url.
window.addEventListener("message", (event) => {
// Messages use a wildcard target origin, so verify the sender.
if (event.origin !== KIRNOVA_FRAME_ORIGIN) return;
const message = event.data;
if (!message || typeof message !== "object") return;
switch (message.type) {
case "BI_ASSESSMENT_COMPLETE":
onSittingFinished(message.data.session_id);
break;
case "BI_RESIZE":
document.getElementById("kirnova-frame").style.height = `${message.data.height}px`;
break;
default:
break;
}
});
function onSittingFinished(sessionId) {
// Move your interface forward, then ask your server to confirm with Kirnova.
fetch(`/internal/kirnova/sittings/${sessionId}/confirm`, { method: "POST" });
}
Important: treat a browser message as a signal to update your interface, not as a record. Messages can be lost if the tab closes and can be forged by other scripts on your page. Confirm every completion server-side with the signed webhook or a result request, and reconcile on the session identifier.
Machine-driven API#
You create a session, receive a page of questions, post the answers, and receive the next page. The last page returns the result. This interface administers every assessment in the table above except TIGER Light, which is never available to a partner. The assessments your account may run are set by Kirnova.
Create a session#
POST /api/v1/sessions
Authorization: Bearer bi_live_sk_XXXXXXXXXXXXXXXXXXXXXXXX
Content-Type: application/json
| Field | Type | Required | Description |
|---|---|---|---|
respondent_email |
string, valid email | One of the three identity fields | Preferred identity. Trimmed and lower-cased |
respondent_phone |
string | One of the three | Accepted as identity, without format validation |
external_respondent_id |
string | One of the three | Your identifier, accepted as identity |
respondent_name |
string | No | Recommended, but a name is not an identity |
test |
string | No | The assessment identifier. Omit only if your account has a default |
metadata |
object | No | Stored and echoed unchanged |
See Respondent identity for the rule.
Response 201:
{
"session_id": "4f7c1b0e-9a2d-4c11-8f3e-6b0a5d2e7c41",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"status": "initiated",
"respondent_id": "cand_88214",
"current_page": 1,
"total_pages": 5,
"section_label": null,
"page_instruction": null,
"questions": [
{ "position": 1, "id": "K2-HEX-CORGA-0007", "text": "...", "page": 1, "position_on_page": 1 },
{ "position": 2, "id": "K2-DISC-CA-0001", "text": "...", "page": 1, "position_on_page": 2 }
]
}
tokenis the session token. Authenticate every later call for this sitting with it, not with your API key. Hold it server-side.respondent_idis yourexternal_respondent_idif you sent one, otherwise the email, otherwise the session identifier. It is not a stable Kirnova identifier.section_labelandpage_instructionarenullfor every TIGER assessment. Do not render them. On the Kirnova assessments they carry the instruction to show above the page (on Kirnova DISC the label also names the half: how the person naturally works, then how they work now).- On a Kirnova assessment the response also carries
scale({ "min": 1, "max": 5 }, ormax3 on the 180-activity Kirnova Calling length, ormax8 on Kirnova Cognitive),response_labelswhere the assessment has fixed anchors, and for Kirnova Calling alicenceobject withattribution,modification_noticeandwarranty_notice. Thelicenceobject is on every page of a Calling sitting, not only the first: render it wherever you show the questions. An integration that ignores these keys parses exactly what it parsed before. - A Kirnova assessment can refuse to start for a respondent, for example during a retake interval. The call returns
409 SITTING_UNAVAILABLEwith a plain message and no session is left open. - The response does not include the resolved assessment. If you rely on an account default, record what you asked for.
Submit a page#
POST /api/v1/sessions/{sessionId}/pages/{pageNumber}
Authorization: Bearer <session token>
Content-Type: application/json
Authenticate with the session token. An API key on this route returns 401 UNAUTHORIZED.
The body is an array of {question_id, value} objects:
{
"responses": [
{ "question_id": "K2-HEX-CORGA-0007", "value": 4 },
{ "question_id": "K2-DISC-CA-0001", "value": 2 }
]
}
value is an integer from 1 to 5 for every question. The 180-activity Kirnova Calling length uses 1 to 3, and each Kirnova Cognitive puzzle uses the option number, 1 to 8. The scale key on each page states the range. A value outside the range, or a non-integer, returns 422 VALIDATION_ERROR on responses.
Rules:
- Pages go in order. Submitting a page other than
current_pagereturns409 SESSION_WRONG_PAGEwithfield: "page_number". - Send exactly one response per question on the page. A mismatch returns
422 VALIDATION_ERRORonresponses. - Every question identifier must belong to that page of the session.
- Each question can be answered once. There is no update path.
- There is no skip and no change-question control.
Response 200 for a page that is not the last:
{
"session_id": "4f7c1b0e-9a2d-4c11-8f3e-6b0a5d2e7c41",
"status": "in_progress",
"submitted_page": 3,
"current_page": 4,
"total_pages": 5,
"progress_pct": 60,
"section_label": null,
"page_instruction": null,
"questions": [
{ "position": 31, "id": "K2-DISC-CA-0014", "text": "...", "page": 4, "position_on_page": 1 }
]
}
Response 200 for the last page:
{
"session_id": "4f7c1b0e-9a2d-4c11-8f3e-6b0a5d2e7c41",
"status": "completed",
"submitted_page": 5,
"engine": "tiger",
"results": { "...": "see TIGER payload" }
}
Branch on engine, not on the page count. If any question in the sitting is unanswered on the final page, the call returns 422 INCOMPLETE_SITTING with the number missing.
On Kirnova Cognitive each question also carries art (absolute addresses of the puzzle picture and its eight options: { kind, problem, options }, where options is one sheet of 4 across and 2 down for png, or a list of eight pictures for svg) and option_order (the order to show the eight options in). Send back the option number, never the position. There is one clock over the whole test, stated on every page as timing (time_limit_seconds and deadline_epoch_ms, on Kirnova's clock). Answers sent after the clock plus a short grace period are not counted: the call returns 409 SITTING_UNAVAILABLE, and the result is built from the answers that arrived in time and delivered as usual. Every puzzle on a page must be answered.
Drive your interface from total_pages and the questions array in each response rather than hard-coding sizes. Today TIGER Core has 5 pages and 50 items, and TIGER Student has 8 pages and 80 items. A few items on each are quality checks, answered on the same 1 to 5 scale; you do not need to identify them.
Recover a failed completion#
Re-posting the final page is safe. If the answers are stored but the completion did not reach you, post the final page again with the same body or an empty responses array. Kirnova re-runs the completion and returns the result. After a session has completed, further submissions return 409 SESSION_ALREADY_COMPLETE.
End a sitting#
POST /api/embed/sessions/{sessionId}/abandon
Authorization: Bearer <session token>
Content-Type: application/json
{ "page_reached": 3 }
page_reached is optional and must be a whole number of 1 or more. The route takes the session token. It returns { "success": true }, marks the sitting abandoned, and queues a session.abandoned webhook if you subscribe. An abandoned sitting cannot be resumed. A missing or wrong token returns 401 UNAUTHORIZED, a malformed body returns 422 VALIDATION_ERROR, and a completed or unknown sitting returns 404 SESSION_NOT_FOUND.
This is the only way a TIGER sitting is recorded as abandoned. A sitting in the frame is never marked abandoned.
Handle webhooks#
Give Kirnova a callback address and choose the events you want. Kirnova sends a signed HTTP POST when a sitting reaches one of them. Webhooks do not depend on a browser staying open.
Events#
| Event | Meaning |
|---|---|
session.completed |
The sitting finished and a result was stored. Sent for TIGER Core, TIGER Student, Kirnova Calling, Kirnova DISC, Kirnova HEXACO, Kirnova ORVIS and Kirnova Cognitive |
session.abandoned |
The sitting was recorded as abandoned. Today this happens only when your server ends a machine-driven sitting |
pdf.ready |
The downloadable document for a sitting has been produced |
job_application.submitted |
Someone applied to a job your organisation posted (see Jobs) |
job_application.stage_changed |
An application to a job your organisation posted moved stage |
These are the only events. Your partner settings screen lists the first three; Kirnova enables the two job events for you.
Payloads#
Every delivery uses the same envelope:
{
"event": "session.completed",
"timestamp": "2026-08-19T09:41:03.118Z",
"tenant_id": "tn_7f2a91c4",
"data": { "...": "per event" }
}
session.completed for TIGER Core and TIGER Student:
{
"event": "session.completed",
"timestamp": "2026-08-19T09:41:03.118Z",
"tenant_id": "tn_7f2a91c4",
"data": {
"session_id": "4f7c1b0e-9a2d-4c11-8f3e-6b0a5d2e7c41",
"respondent_name": "Priya Raman",
"respondent_email": "priya.raman@example.com",
"external_respondent_id": "cand_88214",
"metadata": { "requisition_id": "REQ-4471" },
"completed_at": "2026-08-19T09:41:02.884Z",
"test": "tiger-core",
"result": { "...": "see TIGER payload" }
}
}
The webhook key is result (singular). The result routes return results (plural). Handle both. The webhook body has no pdf block; use pdf.ready or a result request to learn about the document.
For Kirnova Calling, Kirnova DISC and Kirnova HEXACO, the envelope and identity fields are the same, test names the assessment, and result holds the matching payload. Branch on result.engine. For the retired assessment, data carries the identity fields plus disc and tiger blocks and a flat pdf_url. The ocean block was removed on 2026-10-20.
session.abandoned data:
{ "session_id": "...", "respondent_name": "...", "external_respondent_id": "...",
"metadata": {}, "page_reached": 3, "abandoned_at": "2026-08-19T09:22:40.006Z" }
page_reached can be null.
pdf.ready data:
{ "session_id": "...", "respondent_name": "...", "external_respondent_id": "...",
"metadata": {}, "completed_at": "...", "pdf_url": "...", "pdf_expires_at": null }
pdf_expires_at can be null.
Headers and signature#
content-type: application/json
x-bi-webhook-event: session.completed
x-bi-webhook-signature: sha256=9f2c8a1b4d...
x-bi-webhook-timestamp: 1786123456
To verify a delivery:
- The signature header value is prefixed
sha256=. Strip the prefix before comparing the hex digest. - The signature is an HMAC-SHA256 of the request body only, keyed with your webhook secret. Do not include the timestamp, event name or URL.
- Verify against the raw body before any JSON parsing. Re-serialising changes key order and whitespace.
const crypto = require("node:crypto");
const express = require("express");
const app = express();
const WEBHOOK_SECRET = process.env.KIRNOVA_WEBHOOK_SECRET;
function verifyKirnovaSignature(rawBody, headerValue, secret) {
if (typeof headerValue !== "string") return false;
const separator = headerValue.indexOf("=");
if (separator === -1) return false;
const scheme = headerValue.slice(0, separator);
const sent = headerValue.slice(separator + 1);
if (scheme !== "sha256" || sent.length === 0) return false;
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(sent, "utf8");
if (a.length !== b.length) return false; // timingSafeEqual throws on length mismatch
return crypto.timingSafeEqual(a, b);
}
// Use the raw body. Do not parse JSON before this route.
app.post("/webhooks/kirnova",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.get("x-bi-webhook-signature");
if (!verifyKirnovaSignature(req.body, signature, WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = req.get("x-bi-webhook-event");
const payload = JSON.parse(req.body.toString("utf8"));
// Acknowledge first, process afterwards.
res.status(200).end();
enqueue({ event, sessionId: payload.data.session_id, payload });
}
);
Delivery, retries and duplicates#
The first attempt is made when the event happens: when the result is stored for session.completed, when the sitting is closed for session.abandoned, and when the document is stored for pdf.ready.
A failed attempt is retried. There are 5 attempts in total:
| Attempt | Due |
|---|---|
| 1 | When the event happens |
| 2 | 1 minute after attempt 1 failed |
| 3 | 5 minutes after attempt 2 failed |
| 4 | 30 minutes after attempt 3 failed |
| 5 | 2 hours after attempt 4 failed |
A retry that is due is sent in one of two ways. When your server makes an authenticated call to the API, Kirnova sends a few due retries after answering you. A once-a-day sweep sends anything still due. If your server makes no calls, a failed delivery can wait for the next sweep. Recover a missed delivery by requesting the result rather than waiting.
After the fifth failure the delivery is marked failed and is not retried automatically. Contact Kirnova to re-queue it, unless your access window for that result has ended (see Access windows); session.completed and pdf.ready are never sent after the window.
An attempt fails on any non-2xx response, a connection error, or no response within 30 seconds. Return a 2xx immediately and process afterwards.
Delivery time is not guaranteed. Treat webhooks as best-effort notification and the result routes as the authority: if you have not heard about a sitting you believe finished, request the result.
Important: duplicate deliveries are possible. Make your handler idempotent, keyed on the session identifier plus the event name, so a completion processed twice does not create two records.
Get a result#
Use the webhook to learn that a result exists and a read to fetch or confirm it.
| Route | Returns |
|---|---|
GET /api/v1/sessions/{sessionId}/results |
One sitting's full result |
GET /api/v1/tenants/{tenantId}/results |
A dated batch of your sittings |
GET /api/v1/sessions/{sessionId}/pdf |
The document, as bytes |
All three work for TIGER Core, TIGER Student, Kirnova Calling, Kirnova DISC, Kirnova HEXACO and results recorded from the retired assessment.
One sitting#
GET /api/v1/sessions/{sessionId}/results
Authorization: Bearer bi_live_sk_XXXXXXXXXXXXXXXXXXXXXXXX
Authenticate with any of your API keys, including read-only keys, or with the sitting's session token.
{
"session_id": "4f7c1b0e-9a2d-4c11-8f3e-6b0a5d2e7c41",
"status": "completed",
"respondent_name": "Priya Raman",
"respondent_email": "priya.raman@example.com",
"external_respondent_id": "cand_88214",
"metadata": { "requisition_id": "REQ-4471" },
"completed_at": "2026-08-19T09:41:02.884Z",
"result_updated_at": "2026-08-19T09:41:02.884Z",
"ai_enrichment_status": null,
"result_status": "available",
"access_until": "2026-11-17T09:41:02.884Z",
"engine": "tiger",
"results": { "...": "payload for the engine" }
}
engine identifies the payload shape. Branch on it. On a TIGER result the envelope also carries delivered_at (when the copy we first delivered was written) and updated_at (when the reading you are now served was made; null while it is still the delivered one). Other engines do not carry them.
Updated results#
A person's TIGER can be read again when they later complete further Kirnova assessments. While your access window is open and the person's consent to share with you is granted, the TIGER read routes (this single read, the batch listing and your partner pages) return their current TIGER, not only the copy delivered at completion. updated_at is then set and what_changed explains the difference between what you were first sent and what you are sent now. Read it as new information about the same person, not as a sign that your earlier decision was wrong. Nothing is pushed to you: no webhook is sent for an update. If the window has closed, or consent to share with you is not granted or has been withdrawn, you are served the delivered copy as before, or nothing once the window has closed. The first-delivered copy is kept unchanged. Updating the reading does not restart your access window. It is tiger for TIGER Core and TIGER Student, kirnova-calling, kirnova-disc, kirnova-hexaco, or legacy for the retired assessment. A stored result that cannot be read returns 500 RESULT_UNREADABLE rather than a guess.
ai_enrichment_status is always null and is retained for compatibility. Do not branch on it.
A sitting that has not finished scoring returns 409 SCORING_PENDING; retry. The response also carries a progress object beside the error object, with status, answered, total_items, percent_complete, current_page, total_pages, last_activity_at and activity (working, paused or stopped). A null value means no claim is made, not that the person stopped. It contains no answers or scores. If progress cannot be read, the fields are null and the request does not fail.
A session identifier that belongs to another account returns 404 SESSION_NOT_FOUND, never 403.
Access windows#
You can read a TIGER Core or TIGER Student result, and a result from the retired assessment, for 90 days from completion. You can read a Kirnova Calling, Kirnova DISC, Kirnova HEXACO, Kirnova ORVIS or Kirnova Cognitive result for 365 days. Where a result states its own end date (valid_until on DISC, HEXACO, ORVIS and Cognitive), that date applies. Every readable result carries access_until. The person's own access to their result does not end.
After the window, the single read returns 403 RESULT_ACCESS_EXPIRED. Do not retry it. The body has the usual error object, plus what you may still know:
{
"error": { "code": "RESULT_ACCESS_EXPIRED", "message": "Your access to this result has ended." },
"session_id": "4f7c1b0e-9a2d-4c11-8f3e-6b0a5d2e7c41",
"status": "completed",
"external_respondent_id": "cand_88214",
"engine": "tiger",
"completed_at": "2026-08-19T09:41:02.884Z",
"result_status": "access_expired",
"access_expired_at": "2026-11-17T09:41:02.884Z"
}
No score, band, profile code, dimension or document link is included. The batch listing keeps the row with results: null, the document route returns the same error, and webhooks are not sent or re-sent.
A batch of sittings#
GET /api/v1/tenants/{tenantId}/results?from=2026-08-01T00:00:00Z&limit=200
Authorization: Bearer bi_live_sk_XXXXXXXXXXXXXXXXXXXXXXXX
| Parameter | Required | Default | Notes |
|---|---|---|---|
from |
Yes | none | ISO 8601. A missing or malformed value returns 422 VALIDATION_ERROR with field: "from" |
to |
No | now | ISO 8601 |
limit |
No | 100 | Clamped to 1 to 1000 |
offset |
No | 0 | Minimum 0 |
profile |
No | none | Matched against the stored profile code, upper-cased. Only matches results still inside their access window |
environment |
No | live |
live or practice. practice reads sittings run on the older sandbox.kirnova.com host. Any other value returns 422 VALIDATION_ERROR with field: "environment" |
Filtering is on completion time, newest first.
{ "count": 42, "limit": 100, "offset": 0, "environment": "live", "data": [ { "...": "one row per sitting" } ] }
Handle these row variants:
- TIGER: identity fields,
engine: "tiger"andresultswith the TIGER payload. - Kirnova Calling, DISC or HEXACO: identity fields, the matching
engineandresults. - Retired assessment: identity fields,
engine: "legacy", and thediscandtigerblocks flat on the row. Those keys are absent on other rows. Theoceanblock was removed on 2026-10-20. - Unreadable:
engine: null,unreadable: true,results: null. One unreadable row does not fail the page. - Access ended: identity fields,
engine,completed_at,result_status: "access_expired",access_expired: true,access_expired_at,results: nullandprofile: null.
Every readable row carries result_status: "available" and access_until. A TIGER row also carries delivered_at and updated_at, as on the single read. A key belonging to a different account returns 403 FORBIDDEN.
Usage#
GET /api/v1/tenants/{tenantId}/usage
{
"tenant_id": "tn_7f2a91c4",
"period": "2026-08",
"sessions": { "total": 128, "completed": 111, "abandoned": 9, "completion_rate_pct": 86.7 },
"daily_limit": 500,
"today_count": 3
}
period is the current calendar month in UTC. today_count runs from midnight UTC. Sittings made on the older sandbox.kirnova.com host are excluded. daily_limit is null when your tier has no daily cap. On staging the figures count staging sittings only.
The document#
GET /api/v1/sessions/{sessionId}/pdf
Authorization: Bearer bi_live_sk_XXXXXXXXXXXXXXXXXXXXXXXX
The route streams the document with Content-Type: application/pdf, or returns a 302 to storage. Follow redirects. Add ?disposition=inline to serve it inline.
Documents are produced on first request for TIGER Core, TIGER Student, Kirnova DISC, Kirnova HEXACO and Kirnova Calling. The TIGER document is the decision report a company reads on Kirnova, written for the decision your account's standard purpose names. The Kirnova DISC and Kirnova HEXACO documents are written about the person, in the third person. The first request is slower, pdf.available in the result is false until then, and the pdf.ready webhook fires after your first download. Do not wait for pdf.ready before requesting the document. The TIGER document follows the same rule as the result routes. While your access window is open and the person's consent to share with you is granted, and their TIGER has been read again, the document shows the current reading and opens with a "what changed" block: what moved, why, and how it may bear on your earlier decision. In every other case it is the document built from the reading first delivered. An updated document is drawn on each request and the first-delivered one is kept unchanged. The Calling document carries the O*NET licence notice. If the stored result cannot be matched to the result you were sent, the route returns 500 PDF_UNAVAILABLE rather than produce a document. A failed generation is safe to retry, and concurrent requests do not render twice.
After your access window ends, the route returns 403 RESULT_ACCESS_EXPIRED. The document uses Kirnova's presentation and accepts no partner branding.
Result payloads#
A TIGER result is a reading on five dimensions, each with a whole-number estimate, a range around it and a one-word confidence reading (High, Moderate or Low). It also includes a sentence explaining the range, one dimension called out as a relative strength, two worth probing, the kinds of work the readings point at, and a banner when the sitting was not decision-grade. The estimate is a whole number from 1 to 100. It is not a percentile, a pass mark or a profile name.
engine: "tiger"#
For TIGER Core and TIGER Student.
| Field | Type | Description |
|---|---|---|
engine |
"tiger" |
The discriminator |
tier |
"core" or "student" |
Which assessment produced the reading |
contract_version |
string | The payload shape version, "tiger-report-v1" today. Treat it as opaque |
bar_description |
string | The sentence explaining what each range means. Render it near the ranges, unchanged |
dimensions |
array of 5 | One entry per dimension |
dimensions[].name |
"Trust", "Influence", "Grit", "Execution" or "Resilience" |
The dimension |
dimensions[].value |
whole number from 1 to 100 | The estimate for this dimension. Low is 1 to 20, Medium is 21 to 79, High is 80 to 100 |
dimensions[].bar |
object | { lower, estimate, higher, axisMin: 1, axisMax: 100 }. estimate equals value |
dimensions[].presentation |
"two_sided", "at_or_above" or "at_or_below" |
How to word the range: "{lower} to {higher}", "{lower} or above", or "{higher} or below". The one-sided forms avoid implying the scale continues past its end |
dimensions[].meaningLine |
string | A fixed sentence describing what the dimension reads, the same for every respondent. Render it with the dimension |
dimensions[].confidence |
"High", "Moderate" or "Low" |
How sure Kirnova is of the number itself: how likely the true score is to lie within about 12 points of it. It does not depend on where the score sits. Absent on results stored before 2026-08-21 |
strengths |
object | { framing, dimensions[] } with exactly one dimension name |
to_probe |
object | { framing, dimensions[] } with exactly two dimension names, never overlapping strengths |
adaptive_load_reading |
string or null | Words only. Null when quality_banner is non-null |
openness_reading |
object or null | { title, body }. Words only, no number or bar. Present on every TIGER Core and TIGER Student sitting |
quality_banner |
string or null | Non-null means the sitting is not decision-grade. See Rendering requirements |
interests |
object or null | An ordering of interest themes recorded under an earlier edition of TIGER Student. Null on Core and on current Student sittings |
onet_interests |
object or null | The interest report on TIGER Student. Null on Core |
what_changed |
object or null | Null while the result is the one first delivered. When the person's TIGER has been read again and you are served the newer reading, it holds refreshed_at, triggered_by, triggered_at, contributing_sittings, contributing_instruments, changes[] (dimension, before, after, delta, for the dimensions whose number moved) and summary. summary is null when the movement is small; otherwise it has headline, why[], how_it_may_matter, follow_up and framing: why it moved and how that may bear on your earlier decision. See Updated results |
role_families |
object or null | { points_at[], worth_trying[], least_supported[] }, each entry { id, label, core[], supporting[] }, where core and supporting list { dimension, band } with band one of "High", "Medium" or "Low". Null when quality_banner is non-null or no dimension separated enough to point anywhere |
interests_boundary |
string or null | The sentence that separates an interests reading from the five dimensions. Render it above the interests section. Null when neither interests field is present |
decision_report |
object or null | The report written for the decision the sitting was commissioned for, taken from your account's standard purpose. See below |
merged_reading |
object or null | TIGER Student only, and only when the sitting's purpose is placement, development or self-discovery: the jobs built around how the person works, set beside their own readings. Null on Core, and null when the purpose is hiring, promotion, other or not recorded. See below |
pdf |
object | { available, url, expires_at }. Present on the two read routes, not in webhooks. url and expires_at are null until the document exists |
Exactly one of interests and onet_interests is non-null for TIGER Student, and both are null for TIGER Core. Render whichever you receive.
strengths.dimensions and to_probe.dimensions hold dimension names as plain strings (["Grit"]), not objects. Look up the full reading in the top-level dimensions array by name. Render the framing strings as given; they state that these are orderings within one person, not measured gaps or comparisons with other people.
decision_report has the keys interpretation_version, purpose, purpose_label, opening_question, sections[], notices[] and unchanged_truth_line. A section is either { key, source: "payload" }, rendered from fields you already hold, or { key, source: "finding", heading, intro, items[] }, where each item has title, body, listen_for and why_ask. Render unchanged_truth_line and notices with it. Delivered by webhook, it is the reading as it stood at completion, and interpretation_version identifies the wording. purpose is null when your account has no standard purpose. See Reading a result for how to display it.
merged_reading follows the purpose your sitting was started with. For hiring, promotion, "other" or no recorded purpose it is null. For placement, development and self-discovery it carries the list. If your account has no linked company profile, it is treated as an institution for this rule. A result that carries the list is never changed into one without it later, and a hiring result stays without it. When it is present it has the keys for_the_person (always true), heading, preamble[], role_family_overlap, occupation_notes[] (each { occupation_code, occupation_title, distinguishing_styles[], line }), stated_absence, licence ({ source_line, non_endorsement }), vocabulary_map_version and onet_database_version. It is written to the person ("your Grit reads...") and it is career-exploration material, so for_the_person: true means what it says: show it to the person you serve, never to someone choosing between people. Show the two licence lines beside it every time. The notes keep the order of the person's interest list, and nothing in them is a score, a rank or a fit.
confidence, what_changed, role_families, interests_boundary, decision_report and merged_reading are absent on results stored before they were introduced. Treat a missing key as null.
Example, trimmed to one dimension (a real payload has all five and the keys listed above):
{
"engine": "tiger",
"tier": "core",
"contract_version": "tiger-report-v1",
"bar_description": "This bar shows where the score most likely sits.",
"dimensions": [
{
"name": "Trust",
"value": 63,
"bar": { "lower": 55, "estimate": 63, "higher": 71, "axisMin": 0, "axisMax": 100 },
"presentation": "two_sided",
"meaningLine": "Tends to extend trust early and to say what they think in front of people.",
"confidence": "Moderate"
}
],
"strengths": { "framing": "Strongest within this person's own profile. ...", "dimensions": ["Trust"] },
"to_probe": { "framing": "Lowest within this profile, worth probing in an interview. ...", "dimensions": ["Grit", "Influence"] },
"adaptive_load_reading": "We asked about several of these twice, once about how they are in general, and once about how they are when other people are relying on the outcome. ...",
"openness_reading": { "title": "...", "body": "..." },
"quality_banner": null,
"interests": null,
"onet_interests": null,
"pdf": { "available": false, "url": null, "expires_at": null }
}
Casing is mixed. bar_description, contract_version, adaptive_load_reading, openness_reading and quality_banner are snake case; meaningLine, axisMin and axisMax are camel case. Map fields explicitly rather than applying a blanket case conversion.
Partner payloads do not include band probabilities, error variances, standard errors, intervals other than the one on the bar, retest ranges, quality internals, facet-level scores, or anything describing how a score is derived. No percentage, percentile, standard error or coverage figure appears anywhere. The only statement of certainty is the word in dimensions[].confidence.
TIGER Student interest payload#
onet_interests is a complete report object: an instruction, sections, an ordering of interest groups, a six-letter interest code, role groupings, and licence strings (attribution, modification notice, non-endorsement notice, database version, and links to the licence and method pages).
The underlying interest instrument is licensed third-party material. Its licence requires the attribution and non-endorsement notice wherever the report appears. They travel in the payload; render them.
The interest payload carries its own quality note. Do not render it. A sitting has one quality verdict, which quality_banner states.
Interests are never a hiring signal (see Rendering requirements).
engine: "kirnova-calling"#
Kirnova Calling reports an order, not a score. Do not build a per-theme number from it.
| Field | Type | Description |
|---|---|---|
engine |
"kirnova-calling" |
The discriminator |
form |
string | kirnova-calling-saga, kirnova-calling-full or kirnova-calling-core |
contract_version |
string | The payload shape version. Treat it as opaque |
item_set_version |
string | The question set version administered |
ordered_group_names |
array of arrays of strings | The six interest themes, highest first. Themes in the same inner array are tied; do not separate them |
holland_code |
array of 6 strings | The six letters in order, a summary of the ordering above |
role_groups |
array of arrays of strings | Occupations closest to the person's answers, grouped and named. Empty when no match was made |
quality_banner |
string or null | Present when the answers did not hold together well. Show it unchanged wherever the result appears |
quality_outcome |
"CLEAN", "DEGRADED" or "NOT_DECISION_GRADE" |
The same verdict as a code |
attribution, modification_notice, warranty_notice |
string | Required by the licence on every screen that shows this result. Render all three |
method_path |
string | The path on Kirnova's site describing the method. The licence requires it to be reachable |
pdf |
object | { available, url, expires_at } on the two read routes |
engine: "kirnova-disc"#
Kirnova DISC describes working style in four dimensions, each read twice: how the person naturally works, and how they work now. Every number is on a 0 to 100 scale and always comes with its range. It is not a percentile or a comparison with other people.
| Field | Type | Description |
|---|---|---|
engine |
"kirnova-disc" |
The discriminator |
contract_version |
string | The payload shape version. Treat it as opaque |
item_set_version |
string | The question set version administered |
valid_until |
string or null | The last day you may read this result, 365 days after completion |
anchoring |
string | One sentence on what the numbers mean. Show it with the numbers |
dimensions |
array of 4 | One entry per dimension |
adaptive_load |
object or null | { dimensions_adjusting, headline, body }: how far "now" sits from "naturally", in words |
profile |
object or null | { code, title, tied_with, caveat }. A summary name, not an identity. Show caveat wherever you show the name, and treat names in tied_with as equally close |
quality_banner |
string or null | Present when the answers did not hold together well. Show it unchanged |
quality_outcome |
"CLEAN", "DEGRADED", "NOT_DECISION_GRADE" or "NOT_ASSESSED" |
The same verdict as a code |
pdf |
object | { available, url, expires_at } on the two read routes |
Each entry in dimensions holds dimension (D, I, S or C), name, natural, adaptive and adjustment_note. natural and adaptive are each { score, range_low, range_high, interval_text, evidence } or null. interval_text is the range in words and evidence is a one-word strength of the measurement. adjustment_note is a sentence only when the two readings genuinely differ, otherwise null.
engine: "kirnova-hexaco"#
Kirnova HEXACO describes personality in six factors, each with facets. Every factor number is on a 0 to 100 scale and always comes with its range. Facets carry words only.
| Field | Type | Description |
|---|---|---|
engine |
"kirnova-hexaco" |
The discriminator |
contract_version |
string | The payload shape version. Treat it as opaque |
item_set_version |
string | The question set version administered |
valid_until |
string or null | The last day you may read this result, 365 days after completion |
factors |
array of 6 | One entry per factor |
quality_banner |
string or null | Present when the answers did not hold together well. Show it unchanged |
quality_outcome |
"CLEAN", "DEGRADED" or "NOT_DECISION_GRADE" |
The same verdict as a code |
quality_note |
string or null | A further sentence, present only when a particular answering pattern was seen |
pdf |
object | { available, url, expires_at } on the two read routes |
Each entry in factors holds factor, name, score, range_low, range_high, interval_text, standing, evidence and facets. score and its range are null when nothing in that factor was answered. standing is a phrase such as "leans higher", or null. Each facet is { name, standing }, where standing is higher, lower, middle (the answers did not separate) or unmeasured (nothing to read).
engine: "kirnova-orvis"#
Kirnova ORVIS reports an order of eight kinds of work by how much they appealed to the person. It has no number for any theme. An interests reading is never a signal to use in selecting between people, and every payload says so.
| Field | Type | Description |
|---|---|---|
engine |
"kirnova-orvis" |
The discriminator |
contract_version |
string | The payload shape version. Treat it as opaque |
item_set_version |
string | The question set version administered |
valid_until |
string or null | The last day you may read this result, 365 days after completion |
ordered_theme_groups |
array of arrays of strings | The themes, most appealing first. Themes in the same inner array are tied; do not separate them |
use_note |
string | The sentence about use. Show it wherever you show the result |
anchoring |
string | One sentence on what the order means. Show it with the order |
sections |
array | { id, question, answer, caveat }: the report in words, as the person reads it |
quality_banner |
string or null | Present when the answers did not hold together well. Show it unchanged |
quality_outcome |
"CLEAN", "DEGRADED" or "NOT_DECISION_GRADE" |
The same verdict as a code |
pdf |
object | { available, url, expires_at } on the two read routes |
engine: "kirnova-cognitive"#
Kirnova Cognitive measures how well a person solves new puzzles against the clock. The score is on a 0 to 100 scale and always comes with its range and a word. It is not a comparison with other people, and no comparison with other people is ever sent to a partner.
| Field | Type | Description |
|---|---|---|
engine |
"kirnova-cognitive" |
The discriminator |
contract_version |
string | The scoring version. Treat it as opaque |
item_set_version |
string | The puzzle set version administered |
valid_until |
string or null | The last day you may read this result |
score |
integer | 0 to 100 |
range_low, range_high |
integer | The range the score could reasonably sit in. Show it beside the score |
band |
"Low", "Moderate", "High" or "Very high" |
The same reading in a word |
provisional |
boolean | True while the hardest puzzles are still being tuned. The number may move by a few points |
provisional_note |
string or null | The sentence to show when provisional is true |
timed_out |
boolean | True when the clock ran out before the person finished |
time_limit_seconds |
integer | The length of the clock |
quality_banner |
null | Always null. Present so the payload has the same shared keys as the others |
pdf |
object | { available: false }. Kirnova Cognitive has no prepared document |
engine: "legacy"#
The retired behavioural assessment cannot be started, but every result already recorded stays readable. Its scores are served exactly as stored. On 2026-10-20 the ocean block was removed from every channel, so an integration that reads ocean now finds no such key. From the same date tiger.archetype_detail.research_basis is always null; the key stays so a parser that reads it does not break.
disc: {
profile, primary_style, secondary_style,
natural: { D, I, S, C, bands: {D,I,S,C} },
adaptive: { D, I, S, C, bands: {D,I,S,C} },
gap: { D, I, S, C, interpretations: {D,I,S,C} },
profile_label, profile_description, core_strengths[], blindspots[]
}
tiger: { scores{5}, bands{5}, archetype, archetype_detail{...}, guide_version, suppressed }
pdf: { available, url, expires_at }
Values sit within 10 to 90. suppressed: true means the reading is not a measurement and must not be used for a decision.
On the webhook and browser channels, disc.secondary_style is always null, disc.primary_style has no fallback, and the flat pdf_url is always null.
Rendering requirements#
These requirements apply when your interface displays Kirnova results.
- Show a range with every value. Show
bar.lowerandbar.higher, or the wordingpresentationcalls for, wherever you showvalue. A bare number implies a precision the assessment does not have. - Do not order people on the middle number alone. Readings whose ranges overlap are not distinguishable, so where you place people in order, show overlapping readings as level rather than first and second. Any order you show informs a person's decision and never replaces it.
- Always show
quality_bannerwhen present. A non-null banner means the sitting did not meet quality checks and must not be used for a decision. Show the same sentence, unchanged, wherever you show the scores. - Never show a percentile, and never turn the confidence word into a number. Show
dimensions[].confidenceas the word sent: High, Moderate or Low. Do not show a percentile, standard error, probability or coverage figure, or any figure derived from what Kirnova sends. - Do not create a pass mark, cut-off, automated rejection or threshold from any result. No pass mark exists in the product. If a person is filtered out of a process, a person makes that decision.
- Interests are never a hiring signal. A hiring buyer may access an interest result but must not weigh it in a hiring decision. Interests describe what a person is drawn to, not what they are capable of.
- TIGER informs an employment decision; it never takes one. It may inform hiring, promotion, retention, pay, performance, development, management, disciplinary action and termination. In each, the organisation weighs other information, applies its own judgement and complies with employment law.
If you write your own labels, use "middle" to mean "we cannot call this either way", not "average". A reading whose range still covers the midpoint is not an average.
Respondent identity#
There is no anonymous sitting. Every sitting is attached to an identified person before any question is served.
An identity is, in order of preference, respondent_email, respondent_phone or external_respondent_id. A name is not an identity, and the literal string "Participant" is treated as no name.
The two doors have different rules:
| Door | Rule | Failure |
|---|---|---|
POST /api/v1/embed/launch-tokens |
Both respondent_name and respondent_email are required |
422 VALIDATION_ERROR with no field name |
POST /api/v1/sessions |
At least one of email, phone or your own identifier | 422 RESPONDENT_IDENTITY_REQUIRED |
Send an email address wherever you have one. It lets the person reach their own result and keeps records aligned when the same person sits more than once. A respondent identified by email gets a Kirnova account and can sign in to see their own result.
Raw item-by-item answers are retained with every completed sitting, not only the score.
Rate limits#
| Tier | Sessions per day | Requests per minute |
|---|---|---|
| Free | 10 | 100 |
| Standard | 500 | 500 |
| Enterprise | Unlimited | 2,000 |
Limits apply per account, not per key. A negotiated number recorded against your account replaces your tier's. A per-minute number applies to every per-minute route below, and a daily sittings number applies to session creation and appears as daily_limit on the usage route. Contact Kirnova about volume before you build.
| Route | Limit consumed |
|---|---|
POST /api/v1/sessions |
Sessions per day |
POST /api/v1/embed/launch-tokens |
Requests per minute |
POST /api/v1/sessions/{id}/pages/{n} |
Requests per minute |
GET /api/v1/sessions/{id}/results |
Requests per minute |
GET /api/v1/sessions/{id}/pdf |
Requests per minute |
GET /api/v1/tenants/{id}/results, .../usage |
Requests per minute |
GET /api/v1/jobs, GET /api/v1/jobs/{slug}, .../applications, POST .../invitations |
Requests per minute |
| Loading the frame | Not rate limited |
The daily session limit applies only to the machine-driven route. A frame integration is held to the per-minute limit. The launch-token mint consumes the per-minute allowance before the body is validated, so validate before you send.
Responses that pass the limiter carry:
X-RateLimit-Limit: 500
X-RateLimit-Remaining: 487
X-RateLimit-Reset: 1786123456
X-RateLimit-Reset is unix seconds. Limit and Remaining hold the string unlimited where no limit applies, so parse them defensively.
A 429 response adds Retry-After in seconds and one of two bodies:
{ "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Too many requests" } }
{ "error": { "code": "TENANT_DAILY_LIMIT", "message": "Tenant has hit their daily session creation limit" } }
Back off for Retry-After. The per-minute limit clears within the minute. The daily limit clears 24 hours after your first session creation in the current window, not at midnight; X-RateLimit-Reset gives the exact time. The today_count on the usage route counts from midnight UTC and is a different window.
Errors#
Every error from /api/v1/* has this shape:
{ "error": { "code": "SESSION_WRONG_PAGE", "message": "Expected page 4, received page 5", "field": "page_number" } }
field names the offending input where one can be identified, and is absent otherwise. Branch on code, not message; codes are stable and messages can change. Errors inside the frame are HTML and carry no code.
| Code | Status | Meaning | Action | Retry |
|---|---|---|---|---|
UNAUTHORIZED |
401 | Missing, malformed, unknown, revoked or expired key; invalid or expired session token; or an API key on the page-submission route | Fix the credential | No |
FORBIDDEN |
403 | Account not active; a read-only key on a write route; a key used against another account's identifier; or a jobs route used on a job your organisation did not post | Check the key type and account identifier | No |
TEST_REQUIRED |
403 | No assessment named and no default configured | Send test |
No |
TEST_NOT_ENTITLED |
403 | The assessment is not enabled for your account | Ask Kirnova to enable it | No |
TEST_NONE_ENTITLED |
403 | Your account has an empty assessment list | Contact Kirnova | No |
TEST_NOT_EMBEDDABLE |
403 | The assessment can never be delivered through a partner integration. TIGER Light returns this | Do not retry | No |
TEST_NOT_SERVABLE |
403 or 501 | The assessment is yours but cannot be administered on this surface. 403 comes from the entitlement check, 501 from the machine-driven assembly step | Use the other surface or a different assessment. Accept both statuses | No |
TEST_NOT_CONFIGURED |
403 | Your account has no assessment list configured | Ask Kirnova to configure the account | No |
TEST_RETIRED |
403 | The assessment is retired and cannot be started | Name a current assessment | No |
TEST_DISABLED |
403 | The assessment is temporarily switched off by Kirnova | Ask when it returns | Later |
TEST_PAYMENT_REQUIRED |
403 | Reserved for a billing standing problem. Not returned today | Contact Kirnova | No |
VALIDATION_ERROR |
422 | The body, a query parameter or a path segment failed validation | Read field and message. The token-mint route does not name the field |
No |
RESPONDENT_IDENTITY_REQUIRED |
422 | No email, phone or external identifier on a machine-driven session | Send one | No |
INCOMPLETE_SITTING |
422 | The final page was submitted with questions unanswered | The message gives the count. Answer them and resubmit | Yes, once fixed |
RESULT_ACCESS_EXPIRED |
403 | Your access window for this result has ended | Do not retry. Ask the candidate to sit again, or contact Kirnova | No |
SESSION_NOT_FOUND |
404 | Unknown session identifier, or one belonging to another account | Check the identifier and key | No |
NOT_FOUND |
404 | The account identifier in the path does not exist (usage route), or a job does not exist, is a draft or is outside your board (jobs routes). On the batch results route a wrong account identifier is 403 FORBIDDEN |
Check the identifier | No |
JOBS_NOT_ENABLED, NO_LINKED_ACCOUNT, TENANT_NOT_ACTIVE |
403 | Jobs routes only: jobs are not enabled for your account, your account is not linked to an organisation, or your account is not active | Contact Kirnova | No |
NOT_INVITABLE |
409 | A job invitation could not be made: the job is not open or the address was refused | Read the message | No |
SESSION_WRONG_PAGE |
409 | A page other than the expected one was submitted. field: "page_number" |
Resubmit the page named in the message | Yes |
SESSION_ALREADY_COMPLETE |
409 | The sitting has already been scored | Read the result | No |
SESSION_CONTENT_CHANGED |
409 | This machine-driven sitting can no longer be continued. Stored answers are kept; the page just sent is not stored | Start a new session | No |
SCORING_PENDING |
409 | The result is not ready | Back off and retry | Yes |
RATE_LIMIT_EXCEEDED |
429 | Per-minute request limit | Honour Retry-After |
Yes |
TENANT_DAILY_LIMIT |
429 | Daily session-creation limit | Honour Retry-After; X-RateLimit-Reset gives the exact reset |
Yes, later |
SESSION_STATE_INVALID |
500 | The question set for this sitting could not be reassembled | Do not retry in a loop. Send Kirnova the session identifier | No |
SCORING_FAILED |
500 | Scoring or persistence failed at completion | Re-post the final page once. If it persists, contact Kirnova | Once |
RESULT_UNREADABLE |
500 | A stored result is in a shape this interface cannot project | Send Kirnova the session identifier | No |
PDF_UNAVAILABLE |
500 | Document generation failed | Retry once, then contact Kirnova | Once |
LAUNCH_TOKEN_FAILED |
500 | A launch token could not be signed | Retry, then contact Kirnova | Yes |
SESSION_TOKEN_FAILED |
500 | A session token could not be signed | Retry, then contact Kirnova | Yes |
SERVICE_UNAVAILABLE |
503 | Serving is suspended or a dependency is unavailable | Back off and retry | Yes |
TEST_NOT_SERVABLE arrives with two statuses and different messages depending on where the refusal happened, and SESSION_STATE_INVALID is a 500 despite describing a state problem. Branch on the code, not the status.
Operations#
Key rotation#
Request the new key before the old one is revoked. While any unrevoked write-capable key on your account exists, launch tokens minted with another key on the same account continue to verify, so rotation is invisible to respondents. If your last write-capable key is revoked, tokens minted with it stop verifying immediately and anyone holding an unopened frame link is refused.
The safe order is: receive the new key, deploy it, confirm traffic on it, then ask Kirnova to revoke the old one. A lost secret cannot be recovered; it is replaced.
Session lifetime#
| Item | Lifetime | After expiry |
|---|---|---|
| Launch token | 5 minutes from minting | Mint a new one |
| Respondent sitting window | 47 minutes | The sitting cannot be resumed. Start a new one |
| Document download token | 30 minutes | For server-to-server use, use your API key instead |
On the machine-driven route the sitting window runs from session creation. On the frame it runs from the moment the launch link expires, so about 52 minutes from minting. Nothing extends the window, so do not place a sitting behind a step in your own flow that can take half an hour.
Within the window, a respondent can return. On the frame, mint a fresh launch token and load it to resume the same sitting with the answers so far. On the machine-driven route, keep submitting pages with the same session token.
A fresh launch token creates a new session identifier for the same sitting. The completion message, webhook and stored result arrive under the newest identifier, and the earlier one stays in progress. Store every identifier you mint for a person against your own record, and reconcile on the one that completes.
Reporting a problem#
Send the session identifier, the approximate time, the environment and the raw response body including headers to team@kirnova.com.
Jobs on your own site#
You can show Kirnova jobs inside your own site and read them into your own systems. This is off for every account until Kirnova enables it, and you can try it on staging first.
Nobody applies inside your frame. Applying requires the person's own Kirnova account and a completed TIGER Light, and TIGER Light is never shown inside a partner's site. Each job's one button, "Apply with Kirnova", opens the application on Kirnova in a new tab, and the application is credited to your job board.
Which jobs you can show#
Kirnova configures one of three settings for your account:
| Setting | What your board and the jobs API show |
|---|---|
| Off (default) | Nothing. Every jobs route returns 403 JOBS_NOT_ENABLED. An inactive account returns 403 TENANT_NOT_ACTIVE |
| Your own jobs | The open jobs your organisation posted on Kirnova. Your account must be linked to your organisation; otherwise the answer is 403 NO_LINKED_ACCOUNT, not an empty list |
| Every open job | Every open job on Kirnova, for platforms that send people to jobs |
Only open jobs are shown: live, within their thirty days, and not past their closing date. A salary range is always shown. A job whose hiring company is private names only the organisation that posted it.
Reading applications, sending invitations and receiving job webhooks apply only to jobs your organisation posted, whatever the setting.
The frame#
<iframe src="https://embed.kirnova.com/embed/v1/jobs?tenant=<your-slug>"
title="Jobs" style="width:100%;min-height:900px;border:0" loading="lazy"></iframe>
- No token is needed. Your allowed domains list, the same one used for assessment launches, is checked on every page of the board.
- The list supports
q(role),city,remote=1,type,experience,salaryandpage, as on the public jobs page. A job page is/embed/v1/jobs/<job-slug>?tenant=<your-slug>. - It uses your theme and primary colour. It carries no Kirnova name, mark or link except the Apply with Kirnova button.
- Search engines are told not to index it.
Your board on its own address#
Every organisation that posts jobs on Kirnova has a jobs page at https://jobs.kirnova.com/<name>, listing its open jobs, and each job's own page is https://jobs.kirnova.com/<name>/<job>. The name is made from your organisation's name when your first job goes live; change it under Jobs → Your jobs page in the partner portal. Links shared before a change keep working.
The same board can also be on an address your organisation owns, with no frame. Set it up under Embed → Your job board on your own address in the partner portal.
- Your own address, for example
https://careers.yourcompany.com. After you save it, the portal shows one DNS record to add at your DNS provider. That is aCNAMEfor an address likecareers.yourcompany.com, or anArecord for a bare domain. Some addresses also need aTXTrecord to prove ownership. Press Check now once the record is in place. The secure certificate is issued automatically. - On either address,
/is the list and/<job-slug>is one job. The search parameters are the same as the frame's. Nothing else opens on these addresses. Sign-in, accounts and the API stay on Kirnova's own hosts. - The board page may be indexed by search engines. Each job page names the job's page on Kirnova as its canonical address, so a job is listed once.
- Apply with Kirnova opens the application on Kirnova in a new tab, credited to your board, as it does in the frame.
The jobs API#
The API uses the same keys, host, rate limits and error shape as the rest of this guide.
| Route | Key | Description |
|---|---|---|
GET /api/v1/jobs |
Any | The open jobs on your board, with the same search parameters as the frame. Returns jobs, page and has_next_page. No total is returned |
GET /api/v1/jobs/{slug} |
Any | One job as { "job": { ... } }. A job outside your board, a draft or an unknown job returns 404. A closed job returns open: false |
GET /api/v1/jobs/{slug}/applications |
Any | Applications to a job you posted, as applications, page and has_next_page. Filters: stage, source, core, from, to, sort, page. Another organisation's job returns 403; a draft or unknown job returns 404 |
POST /api/v1/jobs/{slug}/invitations |
Write | Invite one person to apply to a job you posted |
Invitation body: { "email": "...", "confirm_adult": true, "send_email": true }. confirm_adult is required and must be true, confirming the person is 18 or over; without it the call returns 422 VALIDATION_ERROR with field: "confirm_adult". Kirnova records who confirmed it and when. With send_email: false, Kirnova sends nothing and you deliver invitation_url yourself. The call returns 201 with invitation_url, expires_at and emailed. A job that is not open, or an address Kirnova refuses, returns 409 NOT_INVITABLE. A job you did not post returns 403.
Kirnova approves each organisation before it can invite people. The person you invite still signs in, completes TIGER Light and applies on Kirnova; an invitation opens the door and does not apply for them.
A job has these fields: slug, open, title, organisation, organisation_line, description, city, country_code, work_mode, employment_type, experience_min_years, experience_max_years, openings, salary (min, max, currency, period, text), skills, closing_date, posted_at, valid_through, public_url, apply_url and embed_url. Open apply_url in a new tab or window; it carries your board so the application is credited to you.
An application has these fields: id, stage, applied_at, routes, core_sent, core_status, stage_history and applicant.
routeslists how the person reached the job:self,recruiter,invited, andpartner_sitewhen it was your own board.stage_historylists each move and when it happened. The hiring team's notes are not included.applicantholdsname,email,phone,city,experience_years,notice_period_days,current_pay,expected_payandpay_currency. It isnullonce the person has withdrawn, because withdrawing takes back the consent that let you see them.- Nothing from TIGER Light is included.
Stages are applied, screening, core_sent, shortlisted, interview, offer, hired, not_selected and withdrawn. Every move is made by a person.
Job webhooks#
Two events are sent, only for jobs your organisation posted and only if you subscribe:
| Event | Sent when |
|---|---|
job_application.submitted |
Someone applies (or applies again after withdrawing), or a recruiter puts someone forward |
job_application.stage_changed |
An application moves stage, including when a Core is sent and when the person withdraws |
{
"event": "job_application.stage_changed",
"timestamp": "2026-09-27T10:00:00.000Z",
"tenant_id": "<your tenant id>",
"data": {
"job_slug": "sales-lead-pune-job00001",
"application_id": "<id>",
"stage": "screening",
"previous_stage": "applied",
"at": "2026-09-27T10:00:00.000Z"
}
}
Job webhooks contain no personal data. To see who applied, read the application through the jobs API, where your key and the person's consent are checked on every call. Signing, retries and duplicates work as described in Handle webhooks.