# AI Interviewer — Partner API > Hand one of your platform's learners over for a voice practice interview, and > get their scored result back. Two calls and a redirect. Base URL: https://62.238.2.15.nip.io/api/partner/v1 OpenAPI: https://62.238.2.15.nip.io/api/partner/v1/openapi.json Index: https://62.238.2.15.nip.io/api/partner/v1/spec ## Authentication, in short Authorization: Bearer the documented form X-API-Key: equivalent, if your client prefers it Two kinds of key: aii_sk___ secret — your server only aii_pk___ publishable — safe in a web page A publishable key can only start anonymous sessions: it can never name a learner or read anyone's results, which is exactly what makes it safe to ship in page source. Everything else needs the secret key from your own backend. --- Everything below is the complete contract, served verbatim from the same `docs/PARTNER_API.md` a person reads, so there is one copy of it rather than two that disagree. Where its examples show a hostname, use the Base URL above instead — that is this deployment. # 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 ``` ```html
``` 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. ```js 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: ` also works if your HTTP client makes that easier. ### Two kinds of key ``` aii_sk___ secret — your server only aii_pk___ 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__`) 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-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. ```json { "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 ```json { "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` ```json { "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 | 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. ```http 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. ```json { "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. ```json { "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 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. ```js // 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'); ``` ```html
``` **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 ```html
``` 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 ```js 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_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. ```json { "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 | ```json { "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). ```json { "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 ```json { "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= ``` 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. ```json { "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`. ```json { "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. ```bash curl -X DELETE "$API/learners/cto_9931" -H "Authorization: Bearer $KEY" ``` ```json { "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 ```bash 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`](examples/partner-quickstart.sh). ### A minimal server-side handler ```python 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.