Integration playbook
This guide takes you from your first API key to live traffic. It gives the order to build in, how to test each stage, and how to know you are finished. For exact fields, endpoints, events and limits, see the partner API reference.
Before you start: read sections 1 to 4 of the API reference so you know which assessment you are running and on which surface.
A product requirement that applies to every integration: a Kirnova result can inform an employment decision but never makes one. There is no pass mark and no automated rejection, and your integration must not add one.
1. Before you start#
1.1 Who sets up what#
| Kirnova sets up | You set up |
|---|---|
| Your partner account | Your live API keys (create and revoke them yourself) |
| Which assessments your account may run | Your webhook address and the events you want |
| Your plan, request limits and session limits | Your branding values |
| The list of web addresses your frame may be embedded on | Your staging key is issued by Kirnova (section 1.4) |
You manage your side in the partner settings screen (section 3). For anything on the left, write to team@kirnova.com.
1.2 What to tell Kirnova#
- Which assessments you need enabled. An account with no assessments configured is refused with
TEST_NOT_CONFIGURED. A request for an assessment you do not hold is refused as well. Kirnova never substitutes a different assessment. - The web addresses you embed the frame on. If your account has a list, requests from other origins, or with no origin, are refused.
1.3 What you need on your side#
- A server that can keep a secret. Your API key mints launch tokens and reads results. It must never reach a browser, a mobile app or a repository.
- An HTTPS endpoint that accepts a POST, verifies a signature and answers within 30 seconds. A slower response counts as a failed delivery.
- A place to store the session identifier against your own record of the person. The session identifier is the join key for messages, result reads and support requests.
1.4 Where you test#
Test on Kirnova staging, a full copy of the service. Staging serves the API, the frame and the app from one host: https://stage.kirnova.com. Production uses https://api.kirnova.com for the API and https://embed.kirnova.com for the frame.
- Staging key. Write to team@kirnova.com to get one. It starts
bi_live_sk_, like a production key, but works only on staging. Tell the environments apart by host, not by key prefix. - Separate systems. Staging and production have separate databases and signing secrets. Keys, launch tokens and session tokens do not work across them. Configure host, key and webhook address together for each environment.
- Test data only. Staging data is not real customer data and can be reset. Use email addresses your team controls.
- Same limits and behaviour. Your plan's daily sitting limit and per-minute request limit apply on staging. Payloads, errors and signatures match production.
Run phases 1 to 3 in section 4 on staging. Phase 4 is the first time you use the production host.
2. Choose a surface#
You can deliver a sitting in a frame that Kirnova renders inside your product, or you can render the questions yourself and drive the sitting call by call. The availability matrix in the API reference (section 1) shows which surfaces each assessment supports.
If both are available, decide on three points.
- Who owns the question screens. With the frame, Kirnova owns layout, wording, answer controls, progress, saving and the closing screen. With the machine-driven route you build all of it, including phone support, slow networks and resume.
- Whether you can host a frame. The frame sets no browser framing restriction. The origins allowed to load it are controlled by your account (section 1.2). Choose the machine-driven route if you cannot host a frame, for example in a native app without a web view or a server-to-server pipeline.
- Whether you need live progress. On TIGER Core and TIGER Student the frame reports only completion and content height. Kirnova Calling sends no browser messages. For a progress bar in your own interface, use the machine-driven route.
| Frame | Machine-driven | |
|---|---|---|
| You build | A page that hosts the frame and one message listener | The full sitting interface, page state, answer collection, retry and resume |
| Kirnova handles | Rendering, saving, resuming, completion | Assembly, validation, scoring, storage |
| Progress visibility | Completion only | Every page |
| Typical time to first sitting | Hours | Days |
| Matching your look | Limited (section 3.1) | Full control |
Most partners should use the frame.
3. What you can do in the partner settings screen#
| Action | Details |
|---|---|
| Create an API key | The secret is shown once. Kirnova stores only a fingerprint, so a lost key is replaced, not recovered. Account owners and team admins can create keys. |
| Revoke an API key | Takes effect immediately. |
| Set your webhook address and events | Subscribe to session.completed. Add pdf.ready if you fetch documents. Add session.abandoned only if you close machine-driven sittings yourself. |
| Set branding values | Logo, two colours, font, button shape, welcome and completion wording, light or dark, and whether the Kirnova name is shown. Only two of these reach a sitting (section 3.1). |
| Add a custom web address | You add the DNS record Kirnova shows you and Kirnova verifies it. Launch addresses do not use custom addresses today. |
| View your sittings | Name, email, status and creation time, filtered by All, Completed, In progress or Abandoned, with a detail view for each. |
You cannot create your own account, change your plan or limits, change which assessments you can run, or change the list of embed origins. Ask Kirnova for those.
3.1 Branding#
On TIGER Core, TIGER Student and Kirnova Calling, two branding values reach the sitting:
- Light, dark, or follow-the-device theme.
- Your primary colour, which Kirnova lightens or darkens as needed to keep text readable and keeps its hue.
Logo, welcome and completion wording and the switch that hides the Kirnova name apply only to the retired earlier assessment. Second colour, font and button shape do not apply to any current sitting. The document generated for a TIGER sitting uses Kirnova's presentation.
If matching your look is a launch requirement, tell Kirnova before you build.
4. The four phases#
Build in this order. Each phase ends with something you can demonstrate, and the test cases in section 10 close it.
| Phase | What you build | What it proves | Closed by |
|---|---|---|---|
| 1. One person, one sitting | Server-side key, the call that starts a sitting, the frame page or your first page screen | Your staging key authenticates, your account is entitled, your identity payload is accepted, and a person can finish | Cases 1 and 2 |
| 2. The result, reliably | Webhook endpoint with signature verification, a read-back call, your own record keyed on session identifier | You receive the result, can prove it came from Kirnova, and can recover it if a message is lost | Cases 3 to 7 |
| 3. Failure | Error-code handling, idempotency, a path for people who stop halfway | The integration survives abandonment, an expired token, an unentitled assessment, a duplicate delivery and a 429 |
Cases 8 to 23 |
| 4. Production | Production key, host and webhook address, registered embed origins, monitoring | The same behaviour with a real person on the production host, and alerting if it stops | Section 11 |
Phase 1 is complete when a person finishes a real sitting and a result is stored, not when a request works in an HTTP client. Phase 2 is complete when your record matches Kirnova's after you deliberately drop a message.
Stored results remain readable to you with no expiry. Keep your own record as the durable copy anyway, so you can reconcile (section 9).
5. The embedded-frame sequence#
sequenceDiagram
autonumber
participant U as Person's browser (your page)
participant P as Your server
participant K as Kirnova API
participant F as Kirnova frame
participant W as Your webhook endpoint
U->>P: "Start my assessment"
P->>K: POST /api/v1/embed/launch-tokens (Bearer key, identity, test)
alt Accepted
K-->>P: 201 {token, expires_at, embed_url, tenant_id, test}
else Key unknown, revoked, or read-only
K-->>P: 401 UNAUTHORIZED / 403 FORBIDDEN
else Assessment not enabled, not on this surface, or account not configured
K-->>P: 403 TEST_NOT_ENTITLED | TEST_NOT_SERVABLE | TEST_REQUIRED | TEST_NOT_CONFIGURED
else Name or email missing
K-->>P: 422 VALIDATION_ERROR
else Too many requests this minute
K-->>P: 429 RATE_LIMIT_EXCEEDED + Retry-After
end
P-->>U: Render the frame with embed_url
U->>F: GET the frame address
alt Launch accepted
F-->>U: Six-point notice, then the first screen once accepted
else Token expired, sitting abandoned, origin not allowed
F-->>U: HTML message card (no status code, no JSON)
end
U->>F: Answers, screen by screen (saved as they go)
F-->>U: postMessage {type: "BI_RESIZE", data: {height}}
F-->>U: postMessage {type: "BI_ASSESSMENT_COMPLETE", data: {session_id}}
F-->>U: postMessage {type: "kirnova:assessment:completed", sessionId}
U->>P: Pass the sessionId to your server
Note over F,K: The result is scored and stored server-side
K->>W: POST session.completed (signed)
alt Your endpoint answers 2xx within 30s
W-->>K: 200
else Non-2xx, timeout, or unreachable
W-->>K: failure, queued for retry (five attempts in total)
end
P->>K: GET /api/v1/sessions/{id}/results (reconciliation)
alt Stored
K-->>P: 200 {engine, results, ...}
else Not scored yet
K-->>P: 409 SCORING_PENDING
end
- Your page asks your server to start a sitting. The browser never calls Kirnova, and the key stays on your server.
- Your server mints a launch token. Both a name and an email address are required. If either is missing, the
422message names both fields whichever was wrong, so check your own payload instead of parsing the message. - Use the returned frame address as is. It carries the token and any display preferences you requested.
- Render the frame promptly. Tokens are short-lived (section 8.2), so mint immediately before rendering and never in advance.
- The person sees a six-point notice first. They press "I understand and acknowledge" before any question appears (API reference, section 4.3). Answers save as they go, so a dropped connection or refresh does not lose them.
- On a TIGER sitting the frame posts two completion messages for the same finish:
{ type: "BI_ASSESSMENT_COMPLETE", data: { session_id } }and{ type: "kirnova:assessment:completed", sessionId }. Act on one only. It also posts{ type: "BI_RESIZE", data: { height } }as content height changes. There are no started, page or abandoned messages. Kirnova Calling posts nothing. Check the message origin, and treat messages as signals to update your interface. They carry no answers or scores and can be lost. - The signed webhook is your record.
- Reconcile by reading the result back (section 9).
Code for these branches: a refusal at step 2 for entitlement rather than authentication; a message card instead of a sitting when the token has expired or the origin is not registered; a completion message that never reaches your page; and a webhook that arrives twice.
6. The machine-driven sequence#
You render the questions. Kirnova assembles, validates and scores. After session creation, calls use the session token returned at creation, not your API key. Sending an API key to a page submission is refused.
sequenceDiagram
autonumber
participant U as Person (your interface)
participant P as Your server
participant K as Kirnova API
P->>K: POST /api/v1/sessions (Bearer key, identity, test)
alt Accepted
K-->>P: 201 {session_id, token, current_page: 1, total_pages, questions[]}
else No usable identity
K-->>P: 422 RESPONDENT_IDENTITY_REQUIRED
else Not enabled, not available on this route, or not configured
K-->>P: 403 TEST_NOT_ENTITLED | 403 or 501 TEST_NOT_SERVABLE | 403 TEST_NOT_CONFIGURED
else Daily sitting limit reached
K-->>P: 429 TENANT_DAILY_LIMIT + Retry-After
end
loop Each page, in order
P-->>U: Render that page's questions
U-->>P: One value, 1 to 5, per question
P->>K: POST /sessions/{id}/pages/{n} (Bearer session token)
alt Right page, right count, every id on this page
K-->>P: 200 {status: "in_progress", current_page: n+1, questions[]}
else Wrong page number
K-->>P: 409 SESSION_WRONG_PAGE ("Expected page X, received page Y")
else Wrong count, unknown id, or repeated id
K-->>P: 422 VALIDATION_ERROR (field: responses)
else Session token expired
K-->>P: 401 UNAUTHORIZED
end
end
P->>K: POST the final page
alt Scored
K-->>P: 200 {status: "completed", engine: "tiger", results: {...}}
else Questions unanswered
K-->>P: 422 INCOMPLETE_SITTING
else Storing or scoring failed
K-->>P: 500 SCORING_FAILED
end
Note over P,K: Re-POST the final page to retry a failed completion (idempotent)
Create a sitting
POST /api/v1/sessions HTTP/1.1
Host: api.kirnova.com
Authorization: Bearer bi_live_sk_9QpX4mZk2LrTvB8sNhCwJd
Content-Type: application/json
{
"respondent_name": "Meera Raghavan",
"respondent_email": "meera.raghavan@example.com",
"external_respondent_id": "LF-CAND-40912",
"test": "tiger-core",
"metadata": { "role_req": "LF-REQ-2291" }
}
HTTP/1.1 201 Created
{
"session_id": "b7d2f118-3a05-4e62-91cc-8ad4e0f37b52",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…",
"status": "initiated",
"respondent_id": "LF-CAND-40912",
"current_page": 1,
"total_pages": 5,
"section_label": null,
"page_instruction": null,
"questions": [
{ "position": 1, "id": "…", "text": "…", "page": 1, "position_on_page": 1 },
{ "position": 2, "id": "…", "text": "…", "page": 1, "position_on_page": 2 }
]
}
Submit a page. Answers are an array of objects, not a map.
POST /api/v1/sessions/b7d2f118-3a05-4e62-91cc-8ad4e0f37b52/pages/1 HTTP/1.1
Host: api.kirnova.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…
Content-Type: application/json
{
"responses": [
{ "question_id": "…", "value": 4 },
{ "question_id": "…", "value": 2 }
]
}
HTTP/1.1 200 OK
{
"session_id": "b7d2f118-3a05-4e62-91cc-8ad4e0f37b52",
"status": "in_progress",
"submitted_page": 1,
"current_page": 2,
"total_pages": 5,
"progress_pct": 20,
"section_label": null,
"page_instruction": null,
"questions": [ "…the next page's questions…" ]
}
The last page returns the finished result:
HTTP/1.1 200 OK
{
"session_id": "b7d2f118-3a05-4e62-91cc-8ad4e0f37b52",
"status": "completed",
"submitted_page": 5,
"engine": "tiger",
"results": { "…the body shown in section 7, step 4…" }
}
Rules for this route
- Submit pages in order. Submitting page 3 when page 2 is expected returns
409naming both. You cannot skip or go back. - Send exactly the number of answers for the page.
- Use only question identifiers Kirnova sent for that page. Each question can be answered once, and there is no correction path.
- Every value is a whole number from 1 to 5.
- Never hardcode a question identifier or page count. Echo back what Kirnova sends, because both vary by assessment and version.
section_labelandpage_instructionare null on every TIGER sitting. Render them conditionally so candidates never see the word "null".- The final page is repeatable. If a call times out on the last page, send it again with the same body or an empty one. It re-runs completion and does not reject you.
- The sitting has a fixed window that starts when the session is created, and nothing refreshes it (API reference, section 14.2). Design the interface so people do not leave mid-sitting and return much later.
- The create call does not report which assessment it resolved. Send
testexplicitly every time and record what you sent. If you omit it, it resolves to your account default or is refused.
7. A complete worked example#
This example follows one sitting through the frame. The person, partner and identifiers are invented: Meera Raghavan, a fictional recruiting firm called Larksfield Talent, and TIGER Core.
Structure, field names, status codes and numbers are exact. Prose fields inside a result are illustrative and differ per sitting. Render them; never match on the wording.
Step 0. Provisioning#
Kirnova enables TIGER Core for the Larksfield account and registers the address the frame will be embedded on. Larksfield creates a key in the settings screen and copies the secret when it is shown:
bi_live_sk_9QpX4mZk2LrTvB8sNhCwJd
They set the webhook address to https://hooks.larksfield.example/kirnova and subscribe to session.completed. The bi_ prefix is a legacy name kept for compatibility.
Step 1. Your server mints a launch token#
POST /api/v1/embed/launch-tokens HTTP/1.1
Host: api.kirnova.com
Authorization: Bearer bi_live_sk_9QpX4mZk2LrTvB8sNhCwJd
Content-Type: application/json
{
"respondent_name": "Meera Raghavan",
"respondent_email": "meera.raghavan@example.com",
"external_respondent_id": "LF-CAND-40912",
"test": "tiger-core",
"metadata": {
"role_req": "LF-REQ-2291",
"stage": "shortlist"
},
"embed_options": {
"theme": "light"
}
}
HTTP/1.1 201 Created
Content-Type: application/json
X-RateLimit-Limit: 500
X-RateLimit-Remaining: 499
X-RateLimit-Reset: 1786312860
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0ZW5hbnRfaWQiOiIzZjlhMWM3Mi01ZDg0In0.KQ2v8mJ0nQ7YbP4sT1cRlX9fWzE6uH3aG5dNqB0oJyM",
"expires_at": "2026-08-19T11:05:00.000Z",
"embed_url": "https://embed.kirnova.com/embed/v1/launch?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0ZW5hbnRfaWQiOiIzZjlhMWM3Mi01ZDg0In0.KQ2v8mJ0nQ7YbP4sT1cRlX9fWzE6uH3aG5dNqB0oJyM&theme=light",
"tenant_id": "3f9a1c72-5d84-4b1e-9e2f-7c0a6b83d411",
"test": "tiger-core"
}
metadata is yours. Kirnova stores it unread and returns it on the webhook, the read-back and the batch listing. Put your requisition, stage and candidate key there to simplify reconciliation.
The response test field echoes the assessment that was resolved. Record it against the candidate.
Step 2. Your page renders the frame#
<iframe
src="https://embed.kirnova.com/embed/v1/launch?token=…&theme=light"
title="Assessment"
width="100%"
height="900"
style="border:0"
></iframe>
Start with a generous height. TIGER sittings send BI_RESIZE messages as content height changes, so you can resize from them. Kirnova Calling sends none, so choose a height that works on a phone or let the frame fill the viewport.
Display preferences travel in the address and are not signed. Treat them as preferences, and do not make anything important depend on them.
Step 3. Your page listens for completion#
<script>
const KIRNOVA_FRAME_ORIGIN = "https://embed.kirnova.com";
window.addEventListener("message", (event) => {
if (event.origin !== KIRNOVA_FRAME_ORIGIN) return;
const msg = event.data;
if (!msg || msg.type !== "kirnova:assessment:completed") return;
// msg.sessionId is a top-level camelCase field. There is no msg.data wrapper
// and no scores or answers. A BI_ASSESSMENT_COMPLETE message for the same
// finish arrives just before this one; this snippet ignores it on purpose.
fetch("/internal/kirnova/sitting-finished", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sessionId: msg.sessionId }),
});
showThankYouScreen();
});
</script>
This applies to TIGER Core and TIGER Student. Kirnova Calling sends no browser message, but its result still reaches you by the signed session.completed webhook and by read-back. The retired earlier assessment used a different message family; see the API reference (section 5).
Step 4. The result arrives at your endpoint#
POST /kirnova HTTP/1.1
Host: hooks.larksfield.example
Content-Type: application/json
x-bi-webhook-event: session.completed
x-bi-webhook-signature: sha256=6f1b0c9d4a7e2f5813c6b9d0a24e7f31c58d92b6e04a7cf13d8e5b207a94c6f1
x-bi-webhook-timestamp: 1786312931
{
"event": "session.completed",
"timestamp": "2026-08-19T11:42:11.004Z",
"tenant_id": "3f9a1c72-5d84-4b1e-9e2f-7c0a6b83d411",
"data": {
"session_id": "a1c47e02-9b3d-4c6f-8f21-5d0e7a94b6c8",
"respondent_name": "Meera Raghavan",
"respondent_email": "meera.raghavan@example.com",
"external_respondent_id": "LF-CAND-40912",
"metadata": { "role_req": "LF-REQ-2291", "stage": "shortlist" },
"completed_at": "2026-08-19T11:42:08.311Z",
"test": "tiger-core",
"result": {
"engine": "tiger",
"tier": "core",
"contract_version": "tiger-report-v1",
"bar_description": "…sentence describing what the range means…",
"dimensions": [
{ "name": "Trust", "value": 68,
"bar": { "lower": 58, "estimate": 68, "higher": 78, "axisMin": 1, "axisMax": 100 },
"presentation": "two_sided", "meaningLine": "…" },
{ "name": "Influence", "value": 44,
"bar": { "lower": 34, "estimate": 44, "higher": 54, "axisMin": 1, "axisMax": 100 },
"presentation": "two_sided", "meaningLine": "…" },
{ "name": "Grit", "value": 78,
"bar": { "lower": 70, "estimate": 78, "higher": 86, "axisMin": 1, "axisMax": 100 },
"presentation": "two_sided", "meaningLine": "…" },
{ "name": "Execution", "value": 52,
"bar": { "lower": 42, "estimate": 52, "higher": 62, "axisMin": 1, "axisMax": 100 },
"presentation": "two_sided", "meaningLine": "…" },
{ "name": "Resilience", "value": 39,
"bar": { "lower": 30, "estimate": 39, "higher": 48, "axisMin": 1, "axisMax": 100 },
"presentation": "two_sided", "meaningLine": "…" }
],
"strengths": {
"framing": "Strongest within this person's own profile. This compares them to themselves, not to other people, and it is an ordering, not a measured difference between their dimensions.",
"dimensions": ["Grit"]
},
"to_probe": {
"framing": "Lowest within this profile, worth probing in an interview. Again an ordering within this person, not a measured gap.",
"dimensions": ["Resilience", "Influence"]
},
"adaptive_load_reading": "…sentence about whether their answers shifted under demand…",
"openness_reading": { "title": "…", "body": "…" },
"quality_banner": null,
"interests": null,
"onet_interests": null
}
}
}
Points to note in this body:
strengths.dimensionsandto_probe.dimensionsare arrays of dimension names (["Grit"]), not objects. Look up the full reading in the top-leveldimensionsarray by name.- The signature header is prefixed
sha256=. Compare against the full header value, not a bare hex digest. - On the webhook, the result is under
data.result(singular). On the read-back API it is underresults(plural). - Some names are snake_case and the fields inside
barare camelCase. Copy the names exactly and do not normalise them. quality_bannerisnullwhen the sitting is decision-grade. When it is not null, it holds a sentence saying the result should not be used for a decision. Show it wherever you show the scores, with the same words for the candidate and the hirer.- Always show each value with its range. Do not show a value alone, order people on the values alone, or derive a threshold from them. If two people's ranges overlap, the instrument does not separate them. See Reading a result.
- This example omits fields added later. A current result also carries a one-word
"confidence"(High, Moderate or Low) on each dimension, and the keyswhat_changed,role_familiesandinterests_boundary. The API reference (section 9.2) defines them.
Step 5. Verify the signature#
The signature covers the raw request body exactly as received. It does not cover the timestamp header, the event header or the URL. Read the raw bytes before any JSON middleware parses them. Re-serialising the parsed object is the most common cause of a signature that never matches.
const crypto = require("crypto");
function verifyKirnovaWebhook(rawBody, signatureHeader, secret) {
if (typeof signatureHeader !== "string") return false;
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(rawBody) // the raw body as received: a Buffer or the exact string
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signatureHeader, "utf8");
if (a.length !== b.length) return false; // timingSafeEqual throws on a length mismatch
return crypto.timingSafeEqual(a, b);
}
An Express endpoint, using express.raw rather than express.json:
app.post("/kirnova",
express.raw({ type: "application/json" }),
(req, res) => {
const ok = verifyKirnovaWebhook(
req.body,
req.get("x-bi-webhook-signature"),
process.env.KIRNOVA_WEBHOOK_SECRET,
);
if (!ok) return res.status(401).end();
const payload = JSON.parse(req.body.toString("utf8"));
res.status(200).end(); // answer first
enqueue(payload); // process afterwards
});
Answer with a 2xx first and process afterwards. A non-2xx response, or one slower than 30 seconds, counts as a failed delivery and starts the retry schedule (API reference, section 7).
Step 6. Read the result back#
Use your API key. A read-only key is enough.
GET /api/v1/sessions/a1c47e02-9b3d-4c6f-8f21-5d0e7a94b6c8/results HTTP/1.1
Host: api.kirnova.com
Authorization: Bearer bi_live_sk_9QpX4mZk2LrTvB8sNhCwJd
HTTP/1.1 200 OK
Content-Type: application/json
{
"session_id": "a1c47e02-9b3d-4c6f-8f21-5d0e7a94b6c8",
"status": "completed",
"respondent_name": "Meera Raghavan",
"respondent_email": "meera.raghavan@example.com",
"external_respondent_id": "LF-CAND-40912",
"metadata": { "role_req": "LF-REQ-2291", "stage": "shortlist" },
"completed_at": "2026-08-19T11:42:08.311Z",
"result_updated_at": "2026-08-19T11:42:08.311Z",
"ai_enrichment_status": null,
"engine": "tiger",
"results": {
"engine": "tiger",
"tier": "core",
"contract_version": "tiger-report-v1",
"…": "the same body as the webhook's data.result",
"pdf": { "available": false, "url": null, "expires_at": null }
}
}
Branch on the top-level engine field, not on which keys are present. It tells you the shape of results. Ignore fields you do not recognise, because new fields are added additively.
A TIGER result read also carries delivered_at and updated_at. If the person's TIGER has been read again since delivery, and consent to share with you is granted, results is the newer reading, updated_at is set and results.what_changed explains the difference. The signed completion message always carries the reading as first delivered. The API reference (section 9.2, "Updated results") has the detail.
If scoring has not finished, the call returns 409 SCORING_PENDING. Wait and retry.
Step 7. Fetch the document#
GET /api/v1/sessions/a1c47e02-9b3d-4c6f-8f21-5d0e7a94b6c8/pdf HTTP/1.1
Host: api.kirnova.com
Authorization: Bearer bi_live_sk_9QpX4mZk2LrTvB8sNhCwJd
This route streams the file. It returns application/pdf bytes or a redirect to storage, which your HTTP client should follow. Add ?disposition=inline to display the file instead of downloading it.
For a TIGER sitting, your first request produces the document, so the first call takes longer and the pdf block reports available: false until then. If you subscribe to pdf.ready, the message arrives when the document is produced. When the person's TIGER has been read again and consent to share with you is granted, the TIGER document shows the current reading and opens with a "what changed" block, as the result routes do.
8. Handling failures#
8.1 The person abandons#
Answers stay saved. Nothing is scored, so no result exists and no completion is sent.
You will see no completion message, no session.completed, and a read-back that returns 409 SCORING_PENDING. In your settings screen, an unfinished frame sitting appears under All. The In progress filter covers only machine-driven sittings with at least one submitted page.
Detect abandonment on your own clock, for example an in-progress sitting older than your staleness limit. A TIGER sitting in the frame is never recorded as abandoned, so no notification is sent. To resume, start the person again with a fresh token. On the machine-driven route you can close a sitting yourself (API reference, section 6.5), which sends session.abandoned. The first attempt is made when you close the sitting, and a failed attempt is retried by the daily sweep.
8.2 The person refreshes or the connection drops#
Nothing is lost. Answers are held server-side, and the same sitting is returned with the same questions. On the machine-driven route, the server still expects the next page in sequence, so request it and continue.
Within the launch token's 5-minute window, presenting the same address again returns the same sitting. After that window the address is refused as an expired launch token, which is the usual case because sittings take longer than the token lives. Mint a fresh launch token for the same person. They resume the same sitting with the same questions and all earlier answers.
Note: a fresh 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 and never completes. Store every identifier you mint for a person against your own record, and reconcile on the one that completes.
Do not build a "restart" button that discards the person's place. Minting a fresh token is the recovery.
8.3 The person returns after the window has closed#
The sitting window lapses. The frame shows a message card saying the session expired, and machine-driven page submissions return 401. Nothing extends the window.
Mint a fresh token so the person can sit again. On the machine-driven route, a lapsed sitting cannot be recovered. Design the experience so it rarely happens: one continuous session, no overnight pauses.
8.4 Your webhook endpoint is down#
A failed attempt is queued for retry. There are five attempts in total, on the schedule in the API reference (section 7). The second attempt is about a minute after the first. Later attempts are sent when due, and a daily sweep is the safety net, so on low-traffic accounts a later attempt can wait until the next day. After the fifth attempt, delivery stops. Do not treat retries as a delivery guarantee.
A silent endpoint looks like a quiet week. Alert on the absence of deliveries, and run the reconciliation sweep (section 9) on a schedule.
8.5 The browser message never arrives#
The person may have closed the tab, a browser extension may have interfered, or your listener may expect the wrong shape. Treat the message as a signal to update your interface only. The webhook and the read-back are your record. If your page must advance, poll your server for the result every few seconds while the page is open.
9. Reconciliation#
A browser message can be lost, and a webhook can arrive twice. Reconciliation makes both harmless.
- Use the session identifier as your idempotency key. Store records keyed on
session_idplus event name. For a repeatedsession.completedon a session you already hold, update in place and do nothing else: no second notification, no second charge, no second result row. - Verify before you read. Verify the signature before looking at the payload and drop anything that fails. A valid body that arrives twice is a duplicate. A body that fails verification is not from Kirnova.
- Reconcile on a schedule. For every sitting you started without a result, ask Kirnova:
GET /api/v1/sessions/{id}/resultsfor a specific person.200means the result exists and you missed it.409 SCORING_PENDINGmeans there is nothing to miss.GET /api/v1/tenants/{tenant_id}/resultsfor a sweep. It lists everything completed in a date range, live sittings by default. The start of the range is required, and paging uses limit and offset. The response carriescount, the requested page and the rows.
Daily sweep. Once a day, request everything completed since your last successful sweep, minus a safety margin. For each row you do not already hold, treat it as if the webhook had just arrived. This closes every delivery failure in section 8.4.
A row whose result cannot be read is marked unreadable instead of failing the whole page. Skip those rows and report them to Kirnova (section 13). Because your access to stored results does not lapse, a sweep that was broken for a fortnight can still recover everything it missed.
10. Test plan#
Run every case on Kirnova staging, using email addresses your team controls. A partner who passes all 23 cases has a working integration. A partner who passes only cases 1 to 7 has a demo.
| # | Do this | Passed when | If it fails |
|---|---|---|---|
| 1 | Mint a token and complete a full sitting, accepting the opening notice and answering every question | The frame shows the six-point notice, reaches its completion screen, and a read-back returns 200 with status: "completed" and five dimensions each with a range |
If minting failed, work through cases 8 to 11 first. The cause is authentication, entitlement, surface or identity |
| 2 | Complete a full sitting on the machine-driven route, page by page | The final page returns status: "completed" and a result body |
A 422 naming responses means your count, identifiers or values are wrong. A 409 means your page number is wrong |
| 3 | Receive session.completed for case 1's sitting |
Your endpoint logged the delivery and your record holds the session identifier | Check your webhook address and subscriptions first |
| 4 | Verify the signature on that delivery | Your verifier returns true using the raw body | You are probably re-serialising the body or comparing without the sha256= prefix |
| 5 | Read the result back by session identifier | 200, and the body matches the webhook |
409 SCORING_PENDING on a finished sitting means you asked too early. Retry |
| 6 | Fetch the document for that sitting | A PDF streams back, or you follow a redirect to one | 409 means scoring is not finished. 500 means generation failed, so report it |
| 7 | Run the batch listing for a range covering case 1 | The sitting appears with your metadata intact |
A 422 naming from means you omitted the required start of the range |
| 8 | Send a request with a revoked or malformed key | 401 UNAUTHORIZED, handled as a configuration problem |
A 403 means the key is valid but the account or key permissions are the issue |
| 9 | Mint with a read-only key | 403 FORBIDDEN |
Use a write key for minting and creating |
| 10 | Ask for an assessment your account is not enabled for | 403 TEST_NOT_ENTITLED |
403 TEST_NOT_CONFIGURED means the account was never configured. Ask Kirnova |
| 11 | Ask for an assessment you hold but that is not available on your surface, for example a frame-only assessment on the machine-driven route | A refusal with TEST_NOT_SERVABLE, as 403 or 501. Branch on the code |
TEST_NOT_ENTITLED means the assessment is not enabled at all, so the case did not test what you intended |
| 12 | Omit the assessment from a create or mint call | Either your account default resolves and is echoed back, or 403 TEST_REQUIRED |
Any other outcome should be reported |
| 13 | Send an identity that is only a name on the machine-driven route, then the literal name Participant with no other identifier |
422 RESPONDENT_IDENTITY_REQUIRED both times |
If a session is created, report it |
| 14 | Mint a token, wait past its window, then load the frame | An HTML message card saying the launch token expired, with no status code or JSON | Mint immediately before rendering, never in advance |
| 15 | Start a sitting, answer one page, close the tab, mint a fresh launch token for the same person and render it | The same questions with earlier answers intact, under a new session_id |
Re-presenting the same address after 5 minutes shows the expired card. Mint again |
| 16 | Load the same launch address a second time while the first is still open, inside the 5-minute window | Both show the same sitting. Reuse does not fail or create a second sitting | If your code blocks reuse, it will block people who reopened a link. After 5 minutes you get the expired card (case 15) |
| 17 | Start a sitting, answer two pages, close the tab, wait | No completion message, no session.completed, a read-back of 409 SCORING_PENDING, and the sitting under All in your settings screen |
Silence is correct. The test is that your own system notices the sitting is stale |
| 18 | Send page 3 when page 2 is expected | 409 SESSION_WRONG_PAGE, naming the pages expected and received, with page_number as the field |
Submit the page named. Do not retry the wrong page |
| 19 | Submit the same page twice, and separately submit a page with one answer missing | The repeat is refused as already submitted, and the short page for wrong count. Both are 422 naming responses |
Fix your page bookkeeping |
| 20 | Post the exact same signed body to your own endpoint a second time | One record, one notification, one charge | A second record means your idempotency key is not the session identifier |
| 21 | Loop a cheap call to exceed the per-minute limit | 429, a Retry-After header, and your client backing off |
There are two 429 codes: per-minute, and the daily sitting limit on machine-driven creation. Retry both after waiting, and never treat them as a failed candidate |
| 22 | Change one character of a received delivery and re-run verification | Verification fails and your endpoint answers 401 without processing |
If it passes, your verifier does not check anything. Write a unit test for this case |
| 23 | Request a session identifier that is not yours | 404 SESSION_NOT_FOUND, never 403 |
If you receive a result, stop and tell Kirnova |
Do not write tests that wait for these, which the cases deliberately do not assert:
- An abandonment notification for a TIGER frame sitting. None is sent.
- The behaviour of a reused launch address after the sitting has finished.
- Recovery of a machine-driven sitting whose window has lapsed. No route offers it.
11. Production checklist#
| # | Item | How to prove it |
|---|---|---|
| 1 | Your API key is held only on your server | Search your front-end bundle and repository for the key prefix and find nothing |
| 2 | You hold a staging key and a production key, each used only against its own host | Your configuration has one host, key and webhook secret per environment, and neither key appears in the other's configuration |
| 3 | The assessment you plan to run is enabled and available on your surface | Case 1 or 2 passes, and case 10 fails for an assessment that is not enabled |
| 4 | Your identity payload always has a usable identifier | Case 13 passes |
| 5 | Tokens are minted immediately before the frame renders | Case 14 passes and your code stores no unused tokens |
| 6 | Your completion listener checks origin and reads the correct message shape | Case 1's page advanced without a manual refresh |
| 7 | Your webhook endpoint is reachable on the production host over HTTPS and answers within 30 seconds | Case 3 passes against the production address, and you can show the response time |
| 8 | Signature verification uses the raw body, runs in constant time and fails closed | Cases 4 and 22 pass |
| 9 | Handling is idempotent on the session identifier | Case 20 passes |
| 10 | You branch on error codes, never on messages | A switch on the code field, with an unknown-code fallback that alerts |
| 11 | You back off on 429 and distinguish the two codes |
Case 21 passes |
| 12 | The reconciliation sweep runs on a schedule and has recovered a dropped delivery | Turn your endpoint off, complete a sitting, turn it on, and let the sweep find it |
| 13 | Your interface shows every value with its range, never orders people on the middle value alone, and shows the quality note whenever present | Screenshot a result with a quality note beside one without |
| 14 | Nothing turns a result into a pass mark, filter or automatic rejection | A named person can show where results are displayed and who decides |
| 15 | The addresses you embed on are registered with Kirnova | Load the frame from an unregistered address and confirm it is refused |
| 16 | Kirnova has your production webhook address and confirmed events | Your settings screen shows both |
| 17 | Someone owns alerting on missing deliveries | Name the person and the alert |
| 18 | Your support desk knows how to handle lost answers, a frame that will not load and data deletion requests | They have read The respondent journey |
12. Troubleshooting#
| You see | Likely cause | What to do |
|---|---|---|
| The frame loads but nothing happens, or shows a plain message card | The launch address was reused after its window, the sitting expired or was abandoned, or your page origin is not registered | Read the card, which names the reason. A launch address older than 5 minutes is expired, so mint a new one and render it immediately. A fresh token resumes the sitting under a new session identifier (section 8.2). If the card names the origin, send Kirnova the address you embed on |
| The frame says the assessment is not available | Serving is paused, or the assessment resolved to one your account cannot run | Retry shortly. If it persists, mint a token directly and read the refusal code from the API response |
| Your listener never fires | You are listening for the wrong message, a nested payload, or a Kirnova Calling sitting | TIGER sends BI_ASSESSMENT_COMPLETE with data.session_id, then kirnova:assessment:completed with a top-level sessionId. Kirnova Calling sends nothing (section 7, step 3) |
| Your listener fires twice for one finish | You act on both completion messages | Act on one only |
session_id is undefined in the listener |
You read event.data.data.session_id |
On a TIGER sitting, use event.data.sessionId |
| No webhook arrived | No address or subscription, a non-2xx or slow response, or an endpoint unreachable from the internet | Check the address and subscriptions in your settings screen, then your own logs, then run the reconciliation sweep. The result exists whether or not the webhook arrived |
| The signature never matches | You hash a re-serialised body, or compare a bare hex digest against a header beginning sha256= |
Capture the raw body before parsing, include the prefix, and use the verifier in section 7, step 5 |
| The signature matches sometimes | Two secrets, one endpoint, two environments | Separate your staging and production endpoints and secrets |
| A validation error on submitting answers | Answers sent as an object instead of an array of {question_id, value}, the wrong count, a question not on that page, or a value outside 1 to 5 |
The response names the field. Compare your body with section 6 and use the questions from the last page response, not from your cache |
409 on every page submission |
You number pages from zero, or retry a page that already landed | The message names the expected and received pages. Submit the one it names |
| A read-back fails after a finished sitting | Scoring was not finished, or the identifier belongs to another account | 409 means wait and retry. 404 means the identifier is not yours |
| A read-back returns a server error naming an unreadable result | Kirnova cannot read the stored result | Send Kirnova the session identifier (section 13). Do not retry in a loop |
An unexpected 429 |
The per-minute bucket counts every call except machine-driven session creation, which uses the daily bucket, and the bucket is shared across your keys | Back off on Retry-After and batch your reads. The batch listing returns a day's sittings in one call |
| The batch listing returns a validation error | The required start of the date range is missing | Always send it, and page with limit and offset |
| A batch row has no result | That result could not be read | Skip it, record the identifier and tell Kirnova |
| Your branding does not appear in the sitting | Only the theme and your primary colour apply, and the colour may be adjusted for readability | See section 3.1 and match the surrounding page instead |
| A person asks about the notice at the start | Every frame sitting opens on a six-point notice | The text is in the API reference (section 4.3). One point tells them Kirnova cannot discuss their results with them |
| A candidate says they finished and you have nothing | The browser message was lost | The webhook and read-back are the record. Look the session up before asking them to sit again |
13. Contacting Kirnova#
Write to team@kirnova.com and include:
- The session identifier. For several, send a list. If you have none, send the respondent's email address and the approximate time.
- When it happened, with the time zone.
- The environment (staging or production) and the host you called.
- The exact request and response, including status code and error code, with your API key and any tokens removed.
- What you expected, in one sentence.
For a webhook problem, add the event header, the timestamp header, and whether your endpoint answered and with what.
Never send a screenshot of a key or token. If you think a key is exposed, revoke it in your settings screen first. Revocation is immediate and does not need Kirnova.