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.

curl
# 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.

curl
# 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.

Node
// 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#

EmbeddedRedirect
Learner leaves your siteNoYes
Work on your sideA div and a script tagA 302
Needs an origin registered with usYesOnly for return_url
Can run with no backend at allYes — see belowNo
How you learn it finishedA message to your page, and the webhookThe 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 backendPublishable key, page only
Learner identityYour external_user_idGenerated anon_…
History across interviewsYesNo
Score against a named studentYesNo
Server code requiredYesNone
Where the key livesYour server, never a pageYour 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
SecretPublishable
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.

PartMeaning
aiiFixed prefix
sk / pkSecret or publishable
live / testEnvironment. A test key against a live key's id is rejected
key_idPublic, 16 hex characters. Identifies the key in our logs
secretThe 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 keylive key
The interview itselfReal. Same interviewer, same scoringReal
Counts against your quotaNoYes
Fires your webhookNoYes
Visible to the other environment's keyNoNo

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.jsonOpenAPI 3.1, partner routes only
GET /api/partner/v1/llms.txtThis whole contract as plain text, one fetch
GET /api/partner/v1/specAn 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#

StatusMeaningRetry?
400Malformed input we can name: a return_url outside your origins, a cursor we did not issue, an over-long Idempotency-KeyNo — fix it
401The key is unknown, wrong, revoked, expired, or presented from an unregistered originNo
403The key is real and authenticated but not allowed here. Almost always a publishable key on a secret-key routeNo — retrying or rotating changes nothing
409A conflict with something that already exists or is in flightYes, later
422The body failed validation. The response names the fieldNo
429Two unrelated causes — see belowYes, 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-Key is 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" }
}
FieldRequiredNotes
external_user_idyesYour stable id for the learner. This is the identity key — see §5. Must not change when they edit their profile
display_nameyesWhat the interviewer calls them
emailnoOnly used for account linking, and only when verified
email_verifiednoDefault false. See §5 — this one matters
positionnoThe role to interview for. Falls back to the platform default configured in our admin
resume_textnoAlready-extracted plain text. We do not fetch URLs
voice_idnoInterviewer voice. Omit for the default (Indian English)
return_urlnoWhere to send the learner afterwards. Must match a configured origin
metadatanoEchoed 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_id is 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_url is 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: true means 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#

StatusMeaningWhat to do
400return_url is not in the allowed origins, or none are configuredFix the URL, or ask us to add the origin
401Bad or missing keyCheck the header
422Payload failed validationThe body names the field
409This learner has had their allowance, or an identical request is still in flightSee §2b — the message says which
429Rate limited, or at your concurrency or monthly ceilingRead 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.

CaseResult
Same key, same body201 with the first response, replayed
Same key, different body422 — use a new key for a new request
Same key, request still running409 — retry in a moment
No keyPrevious 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.

WhatWhereSymptom if missed
Embed originsportal → SettingsThe frame stays blank. Your browser console shows a CSP frame-ancestors violation. No callback fires
Key origins (publishable keys only)portal → API keys401 from /sessions/anonymous, indistinguishable from a bad key
CORS (publishable path only)automatic, from your embed originsThe 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#

codeMeaning
config_errorBoth credentials, or neither
secret_key_refusedAn aii_sk_ key in the page. Nothing was sent. Rotate it
session_failedThe API refused. message is its own words, status the HTTP code
network_errorThe request never arrived — CORS, wrong host, offline. Not a rejected key; a rejected key comes back as session_failed with a 401
launch_failedThe token was already used, or expired
interview_failedThe 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:

CaseResult
Not sentAccount created with an internal placeholder address
Sent, not used by anyone hereUsed as their address
Sent, already belongs to an account, email_verified: trueLinked — same account, so their direct-signup history and their CTOschool history are one
Sent, already belongs to an account, email_verified: falseSeparate 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_rung in §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" }
}
stateMeaning
pendingMinted, nobody has opened the launch URL. Nothing exists yet
expiredNobody opened it in time. Nothing was created and nothing was charged
in_progressThey arrived. The interview is being set up or is running
scoringThe interview ended; the result has not settled
completeThe 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.

PhaseSuggested intervalWhy
Before the learner arrivesDon't pollNothing 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_progressEvery 30–60sYou are waiting on a human having a conversation. Nothing you learn at 5s you would not learn at 60s
Once scoringEvery 10–15sThis is the short window. Scoring settles within a minute or two of the interview ending
Once final is trueStopNothing 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.

QueryNotes
statusactive / completed / abandoned / failed
since, untilISO 8601, applied to creation time. Offsets are honoured
external_user_idYour id for one learner. An id we have never seen returns an empty page, not a 404
cursorFrom next_cursor. Opaque — do not construct one
limitDefault 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#

eventWhen
interview.completedAn interview resolved and was scored
interview.rescoredAn interview was scored again. Same interview_id, new event_id
webhook.testYou 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.

RungLevelWhat it looks like
1FoundationalDefinitions and recall
2AppliedUsing the concept on a familiar problem
3AnalyticalTrade-offs, debugging, edge cases
4DesignOpen-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_deleted is false and retained_reason says which (has_local_login or linked_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.

  1. Sign up, then create your platform. You become its owner.
  2. 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).
  3. Set the default position — used until you start sending position.
  4. Add your return origin, e.g. https://app.example.com. Without it a return_url is rejected rather than silently dropped.
  5. 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.
  6. 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.
  7. Configure your interview profile — length, starting difficulty, question mix — under Interview profiles.
  8. 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.