Partner API
How a learning platform hands one of its learners over for a practice interview and gets their result back. Two calls and one redirect.
https://62.238.2.15.nip.io/api/partner/v1
On this page
For AI agents
Most integrations against this API are now written by a coding agent, so the whole contract is served in forms an agent can take in one fetch. None of them need a key — you are reading a public document, not calling the API.
- The full contract, as plain text
- One unauthenticated request. No navigation, no chrome, nothing to scrape — this is the one to point an agent at.
- OpenAPI schema
- The same endpoints as a schema, for generating a client rather than reading one. It carries the shapes; the prose below carries the reasons, and the reasons are where the mistakes are.
Create a session and redirect
The same call three ways, each one a complete file you can run. Your server makes it and redirects the learner to the launch_url it returns — the browser never holds the key, because a key a browser can read is a key that can start an interview as any learner you have.
# Create a launch for one learner, then send their browser to it.
# Your server does this. Never the browser — this key can act for every
# learner on your platform, and a page that holds it has published it.
API=https://62.238.2.15.nip.io/api/partner/v1
KEY=aii_sk_test_…
curl -sS -X POST "$API/sessions" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"external_user_id": "cto_9931",
"display_name": "Asha Rao",
"position": "Backend Engineer (Python)",
"metadata": {"course_id": "42"}
}'
# → {"session_id": "…", "user_id": "…", "launch_url": "https://…/launch?token=…",
# "expires_at": "…", "resumed": false}
#
# Open launch_url within about two minutes. It is single-use: do not store it,
# email it, or render it as a shareable link.Anonymous sessions, from a page
For a marketing page or a demo with no backend behind it. This is the only route a publishable key can call: it cannot name a learner, which is exactly what makes publishing it safe.
# Start an interview for a visitor with no account and no backend.
# Publishable keys can only do this — naming a learner is a 403 — so this call
# is safe to make from a page, and is checked against your origin allowlist
# instead of being kept secret.
API=https://62.238.2.15.nip.io/api/partner/v1
KEY=aii_pk_test_…
curl -sS -X POST "$API/sessions/anonymous" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{
"display_name": "Guest",
"position": "Backend Engineer (Python)",
"metadata": {"campaign": "homepage-demo"}
}'
# → {"session_id": "…", "user_id": "…", "external_user_id": "anon_5c3f9a…",
# "launch_url": "https://…/launch?token=…", "expires_at": "…", "resumed": false}
#
# Open launch_url within about two minutes. Every call mints a subject that has
# never existed, so per-learner limits cannot apply — your only ceilings here
# are your concurrency limit and your monthly quota.Verify a webhook signature
Results arrive as a signed POST to your webhook URL. The signature is an HMAC-SHA256 over "{t}.{raw_body}" — and raw means before any JSON parsing. This is the one thing nearly everybody gets subtly wrong: a framework that hands you a parsed body has already destroyed the bytes the signature was computed over, and re-serialising that object produces different bytes every time. Both samples read the body themselves, and say so on the line where it matters.
// verify-webhook.mjs — Node 20+, no dependencies.
// AII_WEBHOOK_SECRET=whsec_… node verify-webhook.mjs
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.AII_WEBHOOK_SECRET;
// Reject anything older than five minutes. Without this, a request captured
// once can be replayed at us forever with its original, valid signature.
const TOLERANCE_SECONDS = 300;
createServer((req, res) => {
if (req.method !== 'POST') return res.writeHead(405).end();
// ── Collect the RAW body ourselves. This is the part that goes wrong. ──
//
// The signature is over the exact bytes we sent. Any body parser —
// express.json(), req.json(), a framework that binds to a model — hands you
// a parsed object, and JSON.stringify() of that object is a *different* byte
// string: different key order, different spacing, different unicode escapes.
// The HMAC will then never match no matter how correct the rest of this is.
//
// If you mount this behind Express, this route needs
// express.raw({ type: 'application/json' }) and must be declared BEFORE any
// global express.json().
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
// X-AII-Signature: t=1785321851,v1=<hex>
const header = String(req.headers['x-aii-signature'] ?? '');
const parts = Object.fromEntries(
header.split(',').map(part => {
const at = part.indexOf('=');
return [part.slice(0, at).trim(), part.slice(at + 1).trim()];
})
);
const timestamp = Number(parts.t);
const signature = parts.v1 ?? '';
// The signed material is "{t}.{raw_body}" — the timestamp, a literal dot,
// then the raw body exactly as it arrived. The timestamp is inside the
// signature so it cannot be edited to defeat the freshness check below.
const expected = createHmac('sha256', SECRET)
.update(`${timestamp}.${raw}`)
.digest('hex');
// Constant time. A plain === returns early on the first wrong character,
// which leaks how much of a guess was right.
const valid =
signature.length === expected.length &&
timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) return res.writeHead(400).end('bad signature');
if (!Number.isFinite(timestamp)) return res.writeHead(400).end('bad timestamp');
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
return res.writeHead(400).end('stale');
}
// Only now is it safe to parse.
const event = JSON.parse(raw);
// Deduplicate on interview_id and UPSERT, never append. A re-score arrives
// as interview.rescored with the same interview_id and a new event_id — if
// you append, one interview becomes two attempts in your gradebook.
//
// event.result.overall_score can be null: an interview that ended after
// fewer than three answers comes back assessed:false with
// not_assessed_reason "too_short". Store null and render "not assessed".
// A 0 in that column reads as "this learner is weak" when it means
// "they left".
console.log(event.event, event.interview_id, event.result?.overall_score ?? 'not assessed');
// Answer fast, then do the work. Anything that is not a 2xx is retried for
// about a day; reply 410 Gone to retire the endpoint on purpose.
res.writeHead(200).end('ok');
});
}).listen(4000, () => console.log('listening on http://localhost:4000'));Partner API — integrating a learning platform#
How a learning platform runs an AI practice interview for one of its learners — embedded in its own pages, or by handing them over — and gets the result back.
Base URL: https://62.238.2.15.nip.io/api/partner/v1
Versioned separately from the internal API, so /api/v1 can change without
breaking you.
Status: live — launch, anonymous sessions, polling, result callbacks, usage reporting, delivery visibility and learner erasure are all built.
1. Two ways to run an interview#
Both are fully supported and both use the same API. The difference is only where the learner is while they are being interviewed.
Embedded — they stay on your site#
Your page
┌────────────────────────────────────────┐
│ your header, your navigation │
│ ┌──────────────────────────────────┐ │
│ │ the interview, in a frame │ │ ← they never leave
│ └──────────────────────────────────┘ │
└────────────────────────────────────────┘
│
└── "finished" is a message to your page
<div id="ai-interview"></div>
<script src="https://YOUR-HOST/ai-interviewer/embed.js"></script>
<script>AIInterview.mount('#ai-interview', { token: 'LAUNCH_TOKEN' });</script>
Full recipe in §4d. This is what most integrations want: your learner never sees another company's site, and the interview ends by telling your page rather than by navigating anywhere.
Redirect — they visit us and come back#
Your backend ──POST /sessions──▶ { launch_url }
│
│ 302 the learner's browser
▼
Our site …interview happens… ──▶ back to your return_url
Fewer moving parts, nothing to embed, and no origin to register. The cost is that the learner leaves your product and returns — which is fine for a link in an email or a course page, and wrong for something that should feel like part of your app. Details in §4c.
Which to choose#
| Embedded | Redirect | |
|---|---|---|
| Learner leaves your site | No | Yes |
| Work on your side | A div and a script tag | A 302 |
| Needs an origin registered with us | Yes | Only for return_url |
| Can run with no backend at all | Yes — see below | No |
| How you learn it finished | A message to your page, and the webhook | The webhook, and your return_url |
You can change your mind later. Nothing about the session, the interview, the result or the webhook differs between them.
1b. Where your API key goes, and why it matters#
There are two kinds of key (§2), and which one you can use decides whether your learner has a name.
With a backend — a named learner. Your server holds a secret key, calls
POST /sessions with your own external_user_id, and hands the resulting
launch token to the page. The result comes back attached to that learner, their
history accrues across interviews, and you can put a score against a real
student. This works with either mode above.
Without a backend — an anonymous learner. Your page holds a publishable key and starts the interview itself. Nothing about the visitor is asserted, so we generate a throwaway subject id and return it. Good for a "try it" widget, a marketing page, or evaluating the product before writing any server code.
AIInterview.mount('#ai-interview', { publishableKey: 'aii_pk_live_…' });
These two cannot be combined, and the reason is not a limitation we intend to
lift. A publishable key ships in your page source, so everyone who loads the
page has it. If it could carry an external_user_id, anyone who copied it could
start a session as any learner you have — reading their history and spending
their quota. Anonymous-only is what makes publishing a key safe at all.
| Secret key + your backend | Publishable key, page only | |
|---|---|---|
| Learner identity | Your external_user_id | Generated anon_… |
| History across interviews | Yes | No |
| Score against a named student | Yes | No |
| Server code required | Yes | None |
| Where the key lives | Your server, never a page | Your page, by design |
If you are an LMS putting results in a gradebook, you want the first column. The second is for demos and evaluation.
Never put a secret key in a page. The loader refuses one before it sends anything, but a key that has been served once should be rotated (§2).
2. Authentication#
Every request carries your API key:
Authorization: Bearer aii_sk_live_4f9bce51a4a36401_xNTb1s...
X-API-Key: <key> also works if your HTTP client makes that easier.
Two kinds of key#
aii_sk_<env>_<key_id>_<secret> secret — your server only
aii_pk_<env>_<key_id>_<secret> publishable — safe in a web page
| Secret | Publishable | |
|---|---|---|
| Start a session for a named user | ✅ | ❌ |
| Start an anonymous session | ✅ | ✅ |
| Read results, transcripts, usage | ✅ | ❌ |
| Erase a learner | ✅ | ❌ |
Origin checked against your allowlist | — | ✅ |
A publishable key can never name a user, and that is what makes it safe to
publish. It ships in your page source, so everyone who loads the page has it.
If it could carry an external_user_id, anyone who copied it could start a
session as any learner you have — reading their history and spending their
quota. Anonymous-only is not a limitation of the key; it is the whole reason it
can be public.
Reach for the secret key on your server, and the publishable key only in a browser. A publishable key presented from an origin you have not registered is refused, so add your site's origin before you ship.
| Part | Meaning |
|---|---|
aii | Fixed prefix |
sk / pk | Secret or publishable |
live / test | Environment. A test key against a live key's id is rejected |
key_id | Public, 16 hex characters. Identifies the key in our logs |
secret | The credential. We store only its SHA-256 |
Keys issued before this distinction existed have four segments
(aii_live_<key_id>_<secret>) and are secret keys. They keep working unchanged.
Test and live are separate worlds#
A test key runs real interviews — a sandbox that returned canned answers would prove nothing about a product whose whole value is the conversation. What it does not do is touch anything of yours that matters.
test key | live key | |
|---|---|---|
| The interview itself | Real. Same interviewer, same scoring | Real |
| Counts against your quota | No | Yes |
| Fires your webhook | No | Yes |
| Visible to the other environment's key | No | No |
The two never see each other. GET /interviews on a live key returns live
interviews only; the same call on a test key returns test ones only. A session
or interview id from one environment is a 404 in the other — the same answer
as another company's data, because from that key's point of view it is the same
fact.
This is what makes it safe to develop against your production account. You can wire up your integration, run a dozen interviews on yourself, and neither your month's quota nor the gradebook your live key imports into will know it happened.
Two consequences worth planning for:
- A test interview will never reach your webhook receiver. Nothing is queued, so it does not arrive later either. To exercise a receiver, use Send test event in the portal (§8) — that is what it is for, and it is signed identically to a real delivery.
- Test data does not become live data. There is no promotion step. When you switch to a live key you start with an empty history, which is what you want: the alternative is your first production import carrying your rehearsals.
Check which one you are holding with GET /ping — it reports environment.
A key is shown once, whether you create it yourself in the portal or an administrator issues it. It cannot be recovered — a lost key is replaced, not looked up. Two keys can be usable at once, so rotation is not an outage.
Every authentication failure is 401 {"detail": "Invalid or missing API key"}.
We do not distinguish unknown, wrong, revoked or expired: you cannot act
differently on any of them, and spelling it out would let anyone probe which
keys exist.
A 403 is different and worth handling separately — it means the key is
real and authenticated but is not allowed to do this. In practice that is
almost always a publishable key on a route that needs your secret one.
2b. Errors, and telling them apart#
Machine-readable versions of everything below:
GET /api/partner/v1/openapi.json | OpenAPI 3.1, partner routes only |
GET /api/partner/v1/llms.txt | This whole contract as plain text, one fetch |
GET /api/partner/v1/spec | An index of the two above |
All three are unauthenticated — you read a contract before you have a key.
The codes, and what each actually means#
| Status | Meaning | Retry? |
|---|---|---|
400 | Malformed input we can name: a return_url outside your origins, a cursor we did not issue, an over-long Idempotency-Key | No — fix it |
401 | The key is unknown, wrong, revoked, expired, or presented from an unregistered origin | No |
403 | The key is real and authenticated but not allowed here. Almost always a publishable key on a secret-key route | No — retrying or rotating changes nothing |
409 | A conflict with something that already exists or is in flight | Yes, later |
422 | The body failed validation. The response names the field | No |
429 | Two unrelated causes — see below | Yes, after Retry-After |
429 is two different things, and confusing them will cost you a day#
Rate limiting. Every partner route is rate limited per key, and publishable
keys get a much tighter budget because they are readable by anyone who opens
your page. The response carries Retry-After in seconds. This is about
requests per minute and clears in seconds.
Capacity. Your platform is at its concurrency ceiling or has spent its monthly quota. This clears when an interview finishes, or at the start of the next period — not in seconds.
Read Retry-After before deciding what to do. A client that treats every
429 as "quota exhausted" will back off for the wrong reason and stall an
integration that was merely being asked to slow down. GET /usage tells you
which one you are hitting: if quota.exhausted or concurrency.at_limit is
true it is capacity, and if neither is, it was the rate limit.
409 is also two things on POST /sessions#
- Per-learner limit — this learner has had their allowance for the period. The message names the hour it frees up. Nothing is wrong with your code.
- Idempotency in flight — a request with the same
Idempotency-Keyis still being processed. Retry in a moment and you will get the first response.
3. GET /ping — check your setup#
Cheap, side-effect free, safe to poll from a dashboard. Use it to tell "my key is wrong" from "my code is wrong" without starting an interview to find out.
{
"ok": true,
"platform": "CTOschool",
"environment": "live",
"default_position": "Software Developer",
"interview_profile": "cto-general",
"allowed_return_origins": ["https://app.ctoschool.com"],
"recording": "on"
}
default_position and interview_profile are the two settings that silently
change every interview your learners sit, and allowed_return_origins is the
one that makes return_url fail with a 400. Check them here rather than
guessing.
4. POST /sessions#
Create a launch for one learner.
Request#
{
"external_user_id": "cto_9931",
"display_name": "Asha Rao",
"email": "asha@example.com",
"email_verified": true,
"position": "Backend Engineer (Python)",
"resume_text": "Six years building Django services…",
"voice_id": null,
"return_url": "https://app.ctoschool.com/courses/42/practice",
"metadata": { "course_id": "42", "module_id": "7" }
}
| Field | Required | Notes |
|---|---|---|
external_user_id | yes | Your stable id for the learner. This is the identity key — see §5. Must not change when they edit their profile |
display_name | yes | What the interviewer calls them |
email | no | Only used for account linking, and only when verified |
email_verified | no | Default false. See §5 — this one matters |
position | no | The role to interview for. Falls back to the platform default configured in our admin |
resume_text | no | Already-extracted plain text. We do not fetch URLs |
voice_id | no | Interviewer voice. Omit for the default (Indian English) |
return_url | no | Where to send the learner afterwards. Must match a configured origin |
metadata | no | Echoed back verbatim on the result. Put your own ids here |
Response — 201#
{
"session_id": "0f1c…",
"user_id": "9b2e…",
"launch_url": "https://62.238.2.15.nip.io/ai-interviewer/launch?token=…",
"expires_at": "2026-08-03T11:42:19Z",
"resumed": false
}
user_idis our permanent id for this learner. Stable across every launch. Store it if you want to correlate; you never need to send it back.launch_urlis single-use and lives about two minutes. Redirect to it immediately. Do not store it, email it, or render it as a shareable link — it ends up in browser history as it is.resumed: truemeans this learner already had an interview in progress and this launch rejoins it rather than starting a second one. That is the rejoin-after-a-dropped-connection path, and it is also why a double-clicked button is harmless.
Errors#
| Status | Meaning | What to do |
|---|---|---|
400 | return_url is not in the allowed origins, or none are configured | Fix the URL, or ask us to add the origin |
401 | Bad or missing key | Check the header |
422 | Payload failed validation | The body names the field |
409 | This learner has had their allowance, or an identical request is still in flight | See §2b — the message says which |
429 | Rate limited, or at your concurrency or monthly ceiling | Read Retry-After. See §2b — these are not the same thing |
Nothing is created and nothing is charged until the learner actually opens the launch URL. A session nobody clicks costs you nothing.
Retrying safely — Idempotency-Key#
Optional, and worth sending. If your HTTP client times out and retries, the second call otherwise mints a second launch token: two URLs for one learner, and two interviews against your quota if both get opened.
POST /sessions
Idempotency-Key: 8f14e45f-ea6d-4b1a-9d3c-1c2f4b6a7e90
Send any unique string per intended request — a UUID is ideal. A repeat of the same request with the same key returns the original response, unchanged.
| Case | Result |
|---|---|
| Same key, same body | 201 with the first response, replayed |
| Same key, different body | 422 — use a new key for a new request |
| Same key, request still running | 409 — retry in a moment |
| No key | Previous behaviour: a second call creates a second session |
Keys last 24 hours and are scoped to your platform and to the endpoint, so the
same value on /sessions and /sessions/anonymous will not cross over.
We store only a digest of your request body, never the body itself.
4b. POST /sessions/anonymous#
For a page with no backend of its own. Accepts a publishable key, so it can be called straight from the browser.
{
"display_name": "Guest",
"position": "Backend Engineer (Python)",
"return_url": "https://your-site.example/thanks",
"metadata": { "campaign": "homepage-demo" }
}
Every field is optional. There is deliberately no external_user_id — we
generate a throwaway subject and return it, so you can correlate the eventual
webhook to the visitor.
{
"session_id": "0f1c…", "user_id": "9b2e…",
"external_user_id": "anon_5c3f9a…",
"launch_url": "https://…/launch?token=…",
"expires_at": "2026-08-06T11:42:19Z", "resumed": false
}
Two things to know before you rely on this.
Every call mints a subject that has never existed, so per-learner limits cannot apply — there is no cookie, no login and no id from you to tie two visits together. Your only real ceilings on this route are your platform's concurrency limit and monthly quota. Size a leaked publishable key against those two numbers.
An anonymous learner has no email, so they can never be linked to an existing account and their history begins and ends with that one interview.
4c. Redirecting instead of embedding#
HTTP/1.1 302 Found
Location: https://62.238.2.15.nip.io/ai-interviewer/launch?token=…
The learner lands on a loading screen, the room is created, and the interviewer joins. There is nothing for them to fill in — no name, no role, no upload.
When they finish, we send them to your return_url. If none was set they land
on our site root, which works but is a worse ending.
return_url must be on an allowed origin. Ask an administrator to add
https://app.ctoschool.com to your platform's allowed return origins first;
without it, a return_url is rejected with a 400 rather than silently
dropped. This is an open-redirect guard, not bureaucracy.
A used or expired launch link shows the learner a "this link has already been used" page. That is expected if they hit back — the fix is for them to start the practice interview from your course again.
4d. Embedding the interview in your own page#
The learner never leaves your site. Everything else — the session, the interview, the scoring, the webhook — is identical to §4c.
Before anything works: register your origin#
Do this first. Two of the three checks below fail in ways that look like nothing happening, and all three are separate.
| What | Where | Symptom if missed |
|---|---|---|
| Embed origins | portal → Settings | The frame stays blank. Your browser console shows a CSP frame-ancestors violation. No callback fires |
| Key origins (publishable keys only) | portal → API keys | 401 from /sessions/anonymous, indistinguishable from a bad key |
| CORS (publishable path only) | automatic, from your embed origins | The frame loads, then the page's own API call fails. onError reports network_error |
An origin is scheme and host: https://app.example.com. Not a path, not a
wildcard. http://localhost:3000 is accepted so you can develop locally.
With your backend — a named learner#
Your server mints the token; your page mounts it.
// Your server — secret key, never the browser
const r = await fetch(`${AII}/sessions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AII_SECRET_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
external_user_id: String(learner.id),
display_name: learner.name,
position: course.role,
metadata: { course_id: String(course.id) },
}),
});
const { launch_url } = await r.json();
// Hand the token to your page. Do NOT redirect to launch_url.
const token = new URL(launch_url).searchParams.get('token');
<div id="ai-interview"></div>
<script src="https://YOUR-HOST/ai-interviewer/embed.js"></script>
<script>
AIInterview.mount('#ai-interview', {
token: 'THE_TOKEN_FROM_YOUR_SERVER',
onEnded: function (e) { window.location = '/courses/42/results'; },
onError: function (e) { console.error(e.code, e.message); },
});
</script>
The token is single-use and lives about two minutes. Mint it when the page that mounts the interview is being rendered, not in advance — a token minted at login and used ten minutes later is expired. Nothing is charged for one that is never used.
Without a backend — an anonymous learner#
<div id="ai-interview"></div>
<script src="https://YOUR-HOST/ai-interviewer/embed.js"></script>
<script>
AIInterview.mount('#ai-interview', {
publishableKey: 'aii_pk_live_…',
displayName: 'Guest',
position: 'Backend Engineer',
onSession: function (s) {
// The only time you are told the generated subject id. Keep it if you
// want to match the result webhook to this page view — the API cannot
// look it up for you afterwards.
console.log(s.externalUserId); // "anon_5c3f9a…"
},
onEnded: function () { /* … */ },
});
</script>
Exactly one of token or publishableKey. Sending both is refused rather than
resolved in your favour: preferring the key would silently turn a named session
into an anonymous one and fill your gradebook with anon_… rows.
What your page receives#
AIInterview.mount('#ai-interview', {
token: '…',
onReady: function () {}, // frame loaded
onSession: function (s) {}, // publishable path only
onStarted: function (e) { e.interviewId }, // learner pressed Start
onEnded: function (e) { e.interviewId, e.returnUrl },
onError: function (e) { e.code, e.message, e.status },
onResize: function (e) { e.height }, // handled for you by default
});
onEnded is a signal, not a result. It fires when the interview finishes;
the score does not exist yet. Scoring takes a minute or two, and the answer
arrives on your webhook (§8) or from GET /sessions/{id} (§7). A page that
tries to show a score inside onEnded will show nothing.
The instance also gives you end() — stop the interview as if the learner
pressed the button, for an "are you sure you want to leave?" flow — and
destroy(), which ends it and removes the frame. Call one of them if you
navigate away in a single-page app; a frame removed from the DOM without it
leaves an interview running server-side, burning the learner's attempt and your
quota until the liveness sweeper closes it.
Error codes#
code | Meaning |
|---|---|
config_error | Both credentials, or neither |
secret_key_refused | An aii_sk_ key in the page. Nothing was sent. Rotate it |
session_failed | The API refused. message is its own words, status the HTTP code |
network_error | The request never arrived — CORS, wrong host, offline. Not a rejected key; a rejected key comes back as session_failed with a 401 |
launch_failed | The token was already used, or expired |
interview_failed | The interview could not be created |
Sizing#
The frame reports its height and the loader applies it. Pass height to fix it
instead, or autoResize: false to size it yourself. A minimum of about 640px
is enforced — the room needs it, and a shorter frame scrolls internally in a
way that reads as broken.
One limitation worth knowing#
A hard browser reload of the frame mid-interview will not resume. The session is deliberately held in memory rather than in browser storage — a cross-origin frame's storage is partitioned and can be blocked outright, and a token that never touches disk cannot be. The trade is that a reload cannot pick it back up. The learner starts a new interview; the abandoned one is swept and scored on whatever was said.
Ordinary navigation within your single-page app is fine, provided the frame itself is not torn down and recreated.
Testing it#
embed-example.html is served alongside the loader and exercises both
credential paths with a log of every callback. Point it at a local stack, paste
a launch URL or a publishable key, and watch what happens — including the
failures, which is the part worth seeing before your learners do.
5. How learners are identified#
Matching is on (platform, external_user_id), never on email.
Emails collide across platforms, change, and may not be sent at all. If we matched on email, a learner who updated their address in CTOschool would silently become a new person here and lose their history.
What happens with the email you send:
| Case | Result |
|---|---|
| Not sent | Account created with an internal placeholder address |
| Sent, not used by anyone here | Used as their address |
Sent, already belongs to an account, email_verified: true | Linked — same account, so their direct-signup history and their CTOschool history are one |
Sent, already belongs to an account, email_verified: false | Separate account with a placeholder address |
That last row is deliberate. Auto-linking on an unverified address is an
account-takeover primitive: anyone who could set an email on your side would
inherit the matching account here. Only send email_verified: true if you have
actually verified it.
Learners created this way have no password and cannot sign in here directly.
6. What the interview looks like#
- Voice only. ~15–45 minutes depending on the profile configured for your platform.
- Adaptive difficulty. The interviewer starts at a configured level and
moves up or down as the learner answers, then records where they landed. This
is the most useful signal we produce — see
final_rung/peak_rungin §8. - Duration, difficulty and question types come from the interview profile we configure for your platform, not from the request. Ask us to point your platform at a different profile rather than trying to send overrides — a field that is accepted and ignored is worse than one that does not exist.
7. Checking on a session#
Webhooks (§8) are the better path — you get the result the moment it settles, without asking. Polling exists for the case that actually stops integrations: not everyone has a public HTTPS endpoint on day one.
GET /sessions/{session_id}#
Secret key. Answers even before an interview exists, because it reads the launch you created.
{
"session_id": "0f1c…",
"state": "complete",
"final": true,
"created_at": "2026-08-03T11:40:19Z",
"expires_at": "2026-08-03T11:42:19Z",
"launched_at": "2026-08-03T11:41:02Z",
"interview_id": "3a7b…",
"external_user_id": "cto_9931",
"metadata": { "course_id": "42" },
"interview": { "…": "the GET /interviews/{id} body, or null" }
}
state | Meaning |
|---|---|
pending | Minted, nobody has opened the launch URL. Nothing exists yet |
expired | Nobody opened it in time. Nothing was created and nothing was charged |
in_progress | They arrived. The interview is being set up or is running |
scoring | The interview ended; the result has not settled |
complete | The result is final — scored, too_short, or we gave up scoring |
Poll on final, not on state. It is true for expired and complete
only, and it is the one field that will not gain new values as states are added.
A loop that terminates on state == "complete" will hang forever on a session
nobody opened.
How often, and when to stop#
There is no push here, so the cadence is yours to choose — but a tight loop against a rate-limited endpoint is the usual first attempt, and it fails in a way that looks like our fault.
| Phase | Suggested interval | Why |
|---|---|---|
| Before the learner arrives | Don't poll | Nothing changes until they open the link. Launches expire in ~2 minutes; a session still pending after that is expired and never changes again |
While in_progress | Every 30–60s | You are waiting on a human having a conversation. Nothing you learn at 5s you would not learn at 60s |
Once scoring | Every 10–15s | This is the short window. Scoring settles within a minute or two of the interview ending |
Once final is true | Stop | Nothing after this changes. A loop that keeps going is asking the same question forever |
Give up after about 15 minutes of scoring. Results settle in a minute or
two; past that something has gone wrong, and the state will eventually become
complete with assessed: false rather than hanging. Log it and move on rather
than blocking a user-facing request on it.
A webhook is strictly better if you can receive one (§8). Polling exists for integrations that cannot, not as the recommended path — you get the result the moment it settles, with no interval to tune and no ceiling to stay under.
GET /interviews#
Secret key. Newest first.
| Query | Notes |
|---|---|
status | active / completed / abandoned / failed |
since, until | ISO 8601, applied to creation time. Offsets are honoured |
external_user_id | Your id for one learner. An id we have never seen returns an empty page, not a 404 |
cursor | From next_cursor. Opaque — do not construct one |
limit | Default 25, maximum 100 |
{ "interviews": [ "…" ], "has_more": true, "next_cursor": "MjAyNi0wOC0wM…" }
Paginate with the cursor, not by counting. The list is newest-first over a table that grows at the newest end, so an interview finishing while you walk the pages would push a row across a page boundary and you would never see it — a grade silently missing from an import. The cursor is stable against that.
GET /interviews/{interview_id}#
Secret key. Byte-for-byte the result block the webhook sends (§8), minus
the delivery envelope. One shape, whether you poll or receive — there is a test
comparing them field by field, and a field added to the webhook reaches this
route without being republished.
GET /interviews/{interview_id}/transcript#
Secret key. offset (default 0) and limit (default 200, max 500).
{
"interview_id": "3a7b…",
"turns": [
{ "sequence": 1, "speaker": "interviewer", "text": "…", "at": "2026-08-03T11:50:14Z" }
],
"total_turns": 42, "offset": 0, "limit": 200, "has_more": false
}
speaker is interviewer or candidate. Our system prompt and rubric are not
included — they are not something a person said.
This is the most personal thing we hold about a learner. Treat it accordingly: it is available so you can show someone their own interview, not so it can sit in a log.
8. Results#
When an interview resolves we POST to your configured webhook URL.
Live interviews only. An interview created by a test key is never queued for delivery — not deferred, not held: nothing is written, so configuring a URL later will not drain it (§2). If your receiver is silent while you develop, that is why. Use Send test event below to exercise it.
The event types you can receive#
event | When |
|---|---|
interview.completed | An interview resolved and was scored |
interview.rescored | An interview was scored again. Same interview_id, new event_id |
webhook.test | You pressed Send test event in the portal |
Handle the unknown case rather than throwing. New event types are added by
name, which is what lets you ignore the ones you do not understand — but a
switch that raises on anything unrecognised will fail on the very first
delivery it sees, because webhook.test is what our own setup instructions
tell you to send.
webhook.test is inert on purpose: it carries test: true, a null
interview_id and a null external_user_id, so a receiver that upserts on
interview_id cannot create a row from it. It travels the same outbox and is
signed identically to a real result — which is the entire point, because a test
that took a different path would prove only that the different path works.
When it fires#
Once the interview has both ended and been scored. An interview can end four ways — the learner presses End, the interviewer closes it, it runs past its time, or the learner disappears and we sweep it — and scoring runs afterwards in all four. You get one event, after the result exists, not one per ending.
Body#
{
"event": "interview.completed",
"event_id": "evt_8f21…",
"occurred_at": "2026-08-03T12:04:11Z",
"session_id": "0f1c…",
"interview_id": "3a7b…",
"external_user_id": "cto_9931",
"metadata": { "course_id": "42", "module_id": "7" },
"status": "completed",
"assessed": true,
"not_assessed_reason": null,
"result": {
"overall_score": 6.75,
"scale": 10,
"dimensions": {
"technical": { "score": 7.0, "rationale": "…" },
"communication": { "score": 6.5, "rationale": "…" },
"problem_solving": { "score": 7.0, "rationale": "…" },
"culture_fit": { "score": 6.5, "rationale": "…" }
},
"summary": "…",
"strengths": ["…"],
"weaknesses": ["…"],
"final_rung": 3,
"peak_rung": 4,
"rung_scale": 4,
"duration_seconds": 812,
"candidate_turns": 14,
"started_at": "2026-08-03T11:50:12Z",
"ended_at": "2026-08-03T12:03:44Z"
}
}
Three things to get right in your receiver#
1. overall_score can be null. An interview that ended after fewer than
three answers is marked assessed: false with
not_assessed_reason: "too_short". Scoring two answers on a 0–10 scale produces
a number that reads as "this learner is weak" when it means "they left", and
that number would land in their record. Make the column nullable and render
"not assessed", not 0.
2. status can be abandoned. That is a closed laptop or a dropped
connection, and it is common. It is still a real result for the part that
happened — often with a score.
3. Deduplicate on interview_id, upsert rather than append. Re-scoring an
interview sends interview.rescored with the same interview_id and a new
event_id. If you append, one interview becomes two attempts in your gradebook.
The rungs are the interesting part#
final_rung is where the interviewer settled; peak_rung is the highest the
learner ever reached. A 6/10 earned at rung 4 (open-ended system design) is a
stronger result than an 8/10 at rung 1 (definitions), and the score alone cannot
say so.
| Rung | Level | What it looks like |
|---|---|---|
| 1 | Foundational | Definitions and recall |
| 2 | Applied | Using the concept on a familiar problem |
| 3 | Analytical | Trade-offs, debugging, edge cases |
| 4 | Design | Open-ended architecture, scale, failure modes |
We do not send the hiring-style recommendation (strong_no … strong_yes)
that we use internally. It is the wrong vocabulary for a learner practising, and
showing someone "strong_no" on a practice exercise is both demoralising and
inaccurate about what it measures.
Verifying the signature#
Every delivery carries:
X-AII-Event-Id: evt_8f21…
X-AII-Event-Type: interview.completed
X-AII-Signature: t=1785321851,v1=<hex>
where v1 is HMAC-SHA256(secret, "{t}.{raw_body}"). Compute it over the
raw request body, before any JSON parsing, and reject a t more than five
minutes old. The secret is generated in our admin console and shown once.
Reply 2xx quickly. Anything else is retried with exponential backoff (30s
doubling to a 30-minute ceiling, 12 attempts ≈ a day) — long enough to ride out
a deploy or an outage. Deliveries are durable rows, so a webhook URL configured
later drains the backlog rather than losing it.
Reply 410 Gone if you have retired the endpoint on purpose; that stops the
retries immediately instead of burning a day of them.
Delivery is not instant. Results are queued once scoring has settled, which is within a minute or two of the interview ending — not the moment the learner hangs up. If you need it sooner than that, poll instead.
8b. Usage and deliveries#
Both secret key only.
GET /usage#
What you have consumed, and how close you are to being refused.
{
"period": { "start": "2026-08-01T00:00:00Z", "end": "2026-09-01T00:00:00Z" },
"interviews_started": 412,
"interviews": { "completed": 340, "abandoned": 61, "failed": 4, "active": 7, "other": 0 },
"learners_active": 388, "learners_total": 501, "interviews_all_time": 2914,
"quota": { "limit": 1000, "used": 412, "remaining": 588, "exhausted": false },
"concurrency": { "limit": 25, "live": 7, "available": 18, "at_limit": false,
"peak": 24, "peak_at": "2026-08-04T09:12:00Z" }
}
We meter interviews started, not completed. An abandoned interview
consumed a room, a worker and inference spend. A metric that only counted
finishers would be gamed by hanging up, and the breakdown sums to
interviews_started so the arithmetic is checkable.
limit: null means unlimited, and remaining is then null rather than 0.
concurrency.peak is the number to size against, not live. live reads 0
on a Thursday for a cohort that filled every slot on Tuesday.
These figures are computed the same way the gate that refuses you computes them — there is a test asserting the dashboard says "exhausted" exactly when a session request would be refused. If they ever disagree, that is a bug on our side, not a rounding difference.
GET /deliveries#
The answer to "we never got the result for interview X", which is otherwise a support ticket every time.
Filters: status, event_type, interview_id, limit (default 50, max 200),
offset.
{
"deliveries": [
{ "id": "…", "event_id": "evt_8f21…", "event_type": "interview.completed",
"interview_id": "3a7b…", "status": "failed", "attempts": 6,
"last_status_code": 502, "last_error": "Bad Gateway",
"next_attempt_at": "2026-08-06T14:05:00Z", "payload_bytes": 1841,
"created_at": "2026-08-06T13:58:00Z", "delivered_at": null }
],
"total": 214, "limit": 50, "offset": 0,
"pending": 2, "delivered": 208, "failed": 4
}
The three counts cover all your deliveries, not the filtered page — a headline that changed when you filtered would let an alert disarm itself.
The payload is deliberately not returned. The row is a frozen snapshot, so
serving it would make this an accidental second results API that returns the
older answer after a re-score. Nothing in it diagnoses a delivery failure
anyway: status, attempts, HTTP code and error do. payload_bytes is there so a
receiver rejecting large bodies with a 413 can see why.
POST /deliveries/{id}/retry#
Requeue one. pending and failed are both retryable — failed is the recovery
case, and retrying a pending one pulls its backoff forward, which is the honest
response to "I have just fixed it, try now".
A delivered event cannot be retried (409). Your endpoint returned 2xx, so
re-sending is a duplicate POST of an event you already acted on: a receiver that
deduplicates would ignore it, making the button a lie, and one that does not
would double-write a gradebook or overwrite a re-scored result with the older
snapshot. Fetch the current result from GET /interviews/{id} instead — that
reflects re-scores.
9. DELETE /learners/{external_user_id}#
Call this from your own account-deletion flow. A learner's transcripts, scores and audio live here, not on your platform, so nothing on your side can reach them.
curl -X DELETE "$API/learners/cto_9931" -H "Authorization: Bearer $KEY"
{
"external_user_id": "cto_9931",
"found": true,
"account_deleted": true,
"retained_reason": null,
"interviews_deleted": 3,
"transcript_turns_deleted": 88,
"recordings_deleted": 3,
"resumes_deleted": 1
}
It deliberately deletes less than you might expect, in two cases:
- Only your platform's interviews. A learner who also practises through another platform keeps that history.
- The account survives if anything else uses it. If they also signed up here
directly, or came from a second platform, we remove your link and your
interviews and keep the account.
account_deletedisfalseandretained_reasonsays which (has_local_loginorlinked_to_another_platform). Your interviews are gone either way.
Idempotent. Erasing someone already erased returns 200 with
found: false, not a 404 — so a retrying deletion pipeline does not have to
special-case it.
Recording is on for interviews launched this way, so the audio is real personal data. Your privacy policy should say that practice interviews are recorded and where the recording lives.
10. Try it#
API=https://62.238.2.15.nip.io/api/partner/v1
KEY=aii_sk_test_...
curl -sS -X POST "$API/sessions" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{
"external_user_id": "cto_9931",
"display_name": "Asha Rao",
"position": "Backend Engineer (Python)",
"metadata": {"course_id": "42"}
}' | jq
Open the launch_url in a browser within two minutes.
A runnable version, including the redirect handler, is in
.docs/examples/partner-quickstart.sh
A minimal server-side handler#
import os, uuid, requests
from flask import redirect
AII = "https://62.238.2.15.nip.io/api/partner/v1"
def start_practice_interview(learner, course):
r = requests.post(
f"{AII}/sessions",
headers={"Authorization": f"Bearer {os.environ['AII_SECRET_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),},
json={
"external_user_id": str(learner.id),
"display_name": learner.name,
"email": learner.email,
"email_verified": learner.email_confirmed,
"position": course.interview_role, # once you have one
"resume_text": learner.resume_text, # optional
"return_url": f"https://app.ctoschool.com/courses/{course.id}/practice",
"metadata": {"course_id": str(course.id)},
},
timeout=15,
)
if r.status_code == 429:
return render("try_again_shortly.html")
r.raise_for_status()
return redirect(r.json()["launch_url"], code=302)
Store user_id against your learner the first time you see it. You never have
to send it back — external_user_id is what we match on.
11. Getting set up#
Most of this you now do yourself, in the portal at /ai-interviewer/org.
- Sign up, then create your platform. You become its owner.
- Issue a test key. Immediate, free, and it runs real interviews — but spends no quota, fires no webhook, and stays invisible to your live key. Do your whole integration on one before you issue a live key (§2).
- Set the default position — used until you start sending
position. - Add your return origin, e.g.
https://app.example.com. Without it areturn_urlis rejected rather than silently dropped. - Add your embed origin — required for embedding at all, and for any publishable key. A stronger grant than a return origin: a site listed here can run interviews on your account and spend your quota.
- Set the webhook URL, generate the signing secret, and press Send test event. The test travels the same outbox and is signed the same way as a real result, so a signature check that passes here will pass in production.
- Configure your interview profile — length, starting difficulty, question mix — under Interview profiles.
- Invite your team. Owner, admin or viewer.
Two things still come from us:
- A live key. Test keys are self-service; going live is a conversation, because every interview costs real inference spend and holds a worker.
- Your concurrency cap and monthly quota. These are the bill, so they are ours to set. A cohort all starting at once is the case to size for — tell us the shape of it.