{"openapi":"3.1.0","info":{"title":"AI Interviewer — Partner API","summary":"Hand a learner over for a voice practice interview and get their result back.","description":"Hand one of your learners over for a voice practice interview, and get their\nresult back.\n\n**Base URL:** all paths below are relative to `https://62.238.2.15.nip.io/api/partner/v1`.\n\n## Authentication\n\nEvery call except the three spec routes carries an API key:\n\n```\nAuthorization: Bearer aii_sk_live_4f9bce51a4a36401_xNTb1s...\n```\n\n`X-API-Key: <key>` is accepted as an equivalent alternative.\n\nThere are two kinds of key:\n\n| Key | Prefix | Where it belongs |\n|---|---|---|\n| Secret | `aii_sk_<env>_<key_id>_<secret>` | Your server only |\n| Publishable | `aii_pk_<env>_<key_id>_<secret>` | Safe in a web page |\n\n**A publishable key can only start anonymous sessions** — it can never name a\nlearner or read one's results, and that restriction is the whole reason it is\nsafe to publish. Everything else needs the secret key, presented from your own\nbackend; a publishable key on such a route is a 403.\n\n## Where to start\n\n`POST /sessions` with your id for the learner, then redirect their browser to\nthe `launch_url` you get back. It is single-use and lives about two minutes.\nResults arrive on your configured webhook, or from\n`GET /interviews/{interview_id}` if you poll.\n\nThe full prose contract — including the webhook body, its HMAC signature, how\nlearners are identified, and what the rung levels mean — is one plain-text fetch\nat `https://62.238.2.15.nip.io/api/partner/v1/llms.txt`.\n","version":"v1"},"servers":[{"url":"https://62.238.2.15.nip.io/api/partner/v1","description":"Partner API base URL."}],"paths":{"/api/partner/v1/sessions":{"post":{"tags":["partner"],"summary":"Create Session","description":"Hand a learner over and get a launch URL for their browser.\n\nCreates or resolves the learner here, then returns a single-use launch URL\nwith a two-minute life. Redirect the learner's browser to it; do not store\nit or render it as a link.\n\nNothing is charged and no interview exists until the learner actually\narrives at that URL, so a session minted for someone who never clicks costs\nnothing.\n\n**Secret keys only.** This route takes an `external_user_id` on trust, so\nthe credential presenting it has to be one only the partner's backend holds.\nA page that has no backend to hold one wants `/sessions/anonymous` instead.\n\n### Authentication\n\n**Secret key only** (`aii_sk_…`). Send it as `Authorization: Bearer <key>` or `X-API-Key: <key>`, from your own server. A publishable key (`aii_pk_…`) authenticates fine and is then refused here with a 403: it names or reads a learner, and a credential that ships in page source must not be able to.\n\n### Errors\n\n**`400`** — Your `return_url` is not on one of the platform's allowed return origins, or none are configured. This is an open-redirect guard: the URL is rejected rather than silently dropped, so the failure is visible while you can still fix it. `GET /ping` lists the origins that would be accepted. The `Idempotency-Key` header is longer than the accepted maximum.\n\n**`401`** — `{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.\n\n**`403`** — A publishable key on a route that needs your secret one. The key is real and was authenticated — this is not 401 — so retrying or rotating it changes nothing. Use `aii_sk_…` from your server instead.\n\n**`409`** — This learner has reached the per-learner interview limit set for your platform, and the message says when it lifts. Nothing is rate limited and retrying sooner will not help, which is why this is not a 429. A request with this `Idempotency-Key` is still running. Nothing is rate limited and the work you asked for is already happening, which is why this is not a 429. Retry in a moment and you get the original response.\n\n**`422`** — The request body or query failed validation. The body names the offending field. This `Idempotency-Key` was used for a different request body. A new request needs a new key; reusing one would replay an answer to a question you did not ask. Body is `{\"detail\": \"…\"}` rather than the validation list.\n\n**`429`** — Either your platform is at its concurrency ceiling or its monthly interview quota is exhausted, or the API key is rate limited. `GET /usage` distinguishes the first two before you hit them. Show the learner \"try again shortly\"; do not retry in a tight loop.","operationId":"create_session_api_partner_v1_sessions_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerSessionRequest"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerSessionResponse"}}}},"422":{"description":"The request body or query failed validation. The body names the offending field. This `Idempotency-Key` was used for a different request body. A new request needs a new key; reusing one would replay an answer to a question you did not ask. Body is `{\"detail\": \"…\"}` rather than the validation list.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"400":{"description":"Your `return_url` is not on one of the platform's allowed return origins, or none are configured. This is an open-redirect guard: the URL is rejected rather than silently dropped, so the failure is visible while you can still fix it. `GET /ping` lists the origins that would be accepted. The `Idempotency-Key` header is longer than the accepted maximum.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"401":{"description":"`{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"403":{"description":"A publishable key on a route that needs your secret one. The key is real and was authenticated — this is not 401 — so retrying or rotating it changes nothing. Use `aii_sk_…` from your server instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"409":{"description":"This learner has reached the per-learner interview limit set for your platform, and the message says when it lifts. Nothing is rate limited and retrying sooner will not help, which is why this is not a 429. A request with this `Idempotency-Key` is still running. Nothing is rate limited and the work you asked for is already happening, which is why this is not a 429. Retry in a moment and you get the original response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"429":{"description":"Either your platform is at its concurrency ceiling or its monthly interview quota is exhausted, or the API key is rate limited. `GET /usage` distinguishes the first two before you hit them. Show the learner \"try again shortly\"; do not retry in a tight loop.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}}}}},"/api/partner/v1/sessions/anonymous":{"post":{"tags":["partner"],"summary":"Create Anonymous Session","description":"Start an interview for a visitor who has no account anywhere.\n\nThe route for a customer with no backend of their own: a plain web page\nholding a publishable key posts here and redirects the visitor to the launch\nURL it gets back. Secret keys work too — having a backend is not a reason to\nhave to invent an id for someone who has never signed in to anything.\n\n**The subject id is minted here, never accepted.** That is the entire reason\nthis endpoint exists rather than a flag on `POST /sessions`: a request that\ncould name a learner would be one a public credential could name anyone\nwith. `require_secret_partner` is the other half of the same rule.\n\nThe generated `external_user_id` comes back so the page can match the\neventual result webhook to the visitor it belongs to. Keep it if you need\nthat — nothing here can look it up again afterwards, which is the point.\n\nEverything downstream is the ordinary handoff: same launch token, same\nexchange, same interview, same result. Nothing after this function can tell\nan anonymous session from a named one, and nothing needs to.\n\n### Authentication\n\n**Either key type.** Send it as `Authorization: Bearer <key>` or `X-API-Key: <key>`. A publishable key (`aii_pk_…`) is accepted, and when one is used the browser's `Origin` header must be on the platform's registered embed origin allowlist — a publishable key from an unregistered origin is a 401, not a 403, because it is indistinguishable from a copied key.\n\n### Errors\n\n**`400`** — Your `return_url` is not on one of the platform's allowed return origins, or none are configured. This is an open-redirect guard: the URL is rejected rather than silently dropped, so the failure is visible while you can still fix it. `GET /ping` lists the origins that would be accepted. The `Idempotency-Key` header is longer than the accepted maximum.\n\n**`401`** — `{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.\n\n**`409`** — A request with this `Idempotency-Key` is still running. Nothing is rate limited and the work you asked for is already happening, which is why this is not a 429. Retry in a moment and you get the original response.\n\n**`422`** — The request body or query failed validation. The body names the offending field. This `Idempotency-Key` was used for a different request body. A new request needs a new key; reusing one would replay an answer to a question you did not ask. Body is `{\"detail\": \"…\"}` rather than the validation list.\n\n**`429`** — Either your platform is at its concurrency ceiling or its monthly interview quota is exhausted, or the API key is rate limited. `GET /usage` distinguishes the first two before you hit them. Show the learner \"try again shortly\"; do not retry in a tight loop. The per-learner limit cannot apply on this route — every call mints a subject that has never existed — so your platform's concurrency ceiling and monthly quota are the only ceilings here. Size a leaked publishable key against those two numbers.","operationId":"create_anonymous_session_api_partner_v1_sessions_anonymous_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerAnonymousSessionRequest"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerAnonymousSessionResponse"}}}},"422":{"description":"The request body or query failed validation. The body names the offending field. This `Idempotency-Key` was used for a different request body. A new request needs a new key; reusing one would replay an answer to a question you did not ask. Body is `{\"detail\": \"…\"}` rather than the validation list.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"400":{"description":"Your `return_url` is not on one of the platform's allowed return origins, or none are configured. This is an open-redirect guard: the URL is rejected rather than silently dropped, so the failure is visible while you can still fix it. `GET /ping` lists the origins that would be accepted. The `Idempotency-Key` header is longer than the accepted maximum.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"401":{"description":"`{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"409":{"description":"A request with this `Idempotency-Key` is still running. Nothing is rate limited and the work you asked for is already happening, which is why this is not a 429. Retry in a moment and you get the original response.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"429":{"description":"Either your platform is at its concurrency ceiling or its monthly interview quota is exhausted, or the API key is rate limited. `GET /usage` distinguishes the first two before you hit them. Show the learner \"try again shortly\"; do not retry in a tight loop. The per-learner limit cannot apply on this route — every call mints a subject that has never existed — so your platform's concurrency ceiling and monthly quota are the only ceilings here. Size a leaked publishable key against those two numbers.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}}}}},"/api/partner/v1/learners/{external_user_id}":{"delete":{"tags":["partner"],"summary":"Erase Learner","description":"Delete a learner's interviews, transcripts, scores and audio.\n\nCall this from your own account-deletion flow. Their practice interviews\nlive here, not on your platform, so nothing on your side can reach them.\n\nWhat it removes, and what it deliberately does not:\n\n* Only **your** platform's interviews. A learner who also practises through\n  another platform keeps that history.\n* The account itself only when nothing else depends on it. If they also\n  signed up here directly, or arrived from a second platform, the link and\n  your interviews go and the account stays — the response says which.\n\nIdempotent. Erasing someone already erased returns `found: false` rather\nthan a 404, so a retrying deletion pipeline does not have to special-case\nit.\n\n**Secret keys only** — a publicly readable credential that destroys a named\nperson's data is not a credential, it is a vandalism button.\n\nAnonymous visitors cannot be erased through here in practice, and there is\nnothing to erase: their subject id exists only in the response their page\nreceived, and none of it is attached to a person we or the partner can name.\n\n### Authentication\n\n**Secret key only** (`aii_sk_…`). Send it as `Authorization: Bearer <key>` or `X-API-Key: <key>`, from your own server. A publishable key (`aii_pk_…`) authenticates fine and is then refused here with a 403: it names or reads a learner, and a credential that ships in page source must not be able to.\n\n### Errors\n\n**`401`** — `{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.\n\n**`403`** — A publishable key on a route that needs your secret one. The key is real and was authenticated — this is not 401 — so retrying or rotating it changes nothing. Use `aii_sk_…` from your server instead.\n\n**`429`** — Rate limited on the API key. Carries `Retry-After` in seconds. Publishable keys get the tighter budget, because they are the ones a script can lift off a web page.\n\n**This operation does not declare a typed response model**, so no schema for its success body appears in this document. Do not infer one. The exact fields are in the prose contract at `/api/partner/v1/llms.txt`.","operationId":"erase_learner_api_partner_v1_learners__external_user_id__delete","parameters":[{"name":"external_user_id","in":"path","required":true,"schema":{"type":"string","title":"External User Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"`{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"403":{"description":"A publishable key on a route that needs your secret one. The key is real and was authenticated — this is not 401 — so retrying or rotating it changes nothing. Use `aii_sk_…` from your server instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"429":{"description":"Rate limited on the API key. Carries `Retry-After` in seconds. Publishable keys get the tighter budget, because they are the ones a script can lift off a web page.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}}}}},"/api/partner/v1/ping":{"get":{"tags":["partner"],"summary":"Ping","description":"Check the key works and see how this platform is configured.\n\nExists so a partner can tell \"my key is wrong\" from \"my code is wrong\"\nwithout starting an interview to find out. Cheap, side-effect free, and safe\nto poll from a dashboard.\n\nIt also answers the question that actually causes support tickets: which\nposition and interview profile their learners will get, and whether a\nreturn URL will be accepted.\n\nAccepts either kind of key. A page holding a publishable key needs this most\nof all — it is how the page checks its own setup, and a publishable key that\nis refused here is one whose origin allowlist is wrong, which is the single\nmost common way an embed fails to work.\n\n### Authentication\n\n**Either key type.** Send it as `Authorization: Bearer <key>` or `X-API-Key: <key>`. A publishable key (`aii_pk_…`) is accepted, and when one is used the browser's `Origin` header must be on the platform's registered embed origin allowlist — a publishable key from an unregistered origin is a 401, not a 403, because it is indistinguishable from a copied key.\n\n### Errors\n\n**`401`** — `{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.\n\n**`429`** — Rate limited on the API key. Carries `Retry-After` in seconds. Publishable keys get the tighter budget, because they are the ones a script can lift off a web page.\n\n**This operation does not declare a typed response model**, so no schema for its success body appears in this document. Do not infer one. The exact fields are in the prose contract at `/api/partner/v1/llms.txt`.","operationId":"ping_api_partner_v1_ping_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"401":{"description":"`{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"429":{"description":"Rate limited on the API key. Carries `Retry-After` in seconds. Publishable keys get the tighter budget, because they are the ones a script can lift off a web page.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}}}}},"/api/partner/v1/sessions/{session_id}":{"get":{"tags":["partner"],"summary":"Get Session","description":"What became of one launch.\n\nThe endpoint to poll if you cannot receive a webhook. It answers the four\nquestions a caller actually has, and keeps them apart:\n\n* **Nobody opened it.** `pending` while the launch URL is still live,\n  `expired` once it is not. An expired session created nothing and cost\n  nothing.\n* **They are in there now.** `in_progress`.\n* **It is over, and the result is not ready.** `scoring`. This is the state\n  that has to exist separately: an interview ends and evaluation runs\n  afterwards, so for a minute or two there is a finished interview with a\n  null score. A caller who read that as the final answer would record \"not\n  assessed\" for a learner who scored 8.\n* **It is over and settled.** `complete` — scored, deliberately not scored\n  because it was too short, or scoring failed and we are saying so rather\n  than leaving you waiting.\n\n`final` collapses those five into the only question a loop needs to ask.\nStop when it is true; nothing after that will change.\n\nThe `interview` field, once present, is exactly what `GET /interviews/{id}`\nreturns and exactly what your webhook receives, so the function that records\na result does not need to know which of the three it was handed.\n\n### Authentication\n\n**Secret key only** (`aii_sk_…`). Send it as `Authorization: Bearer <key>` or `X-API-Key: <key>`, from your own server. A publishable key (`aii_pk_…`) authenticates fine and is then refused here with a 403: it names or reads a learner, and a credential that ships in page source must not be able to.\n\n### Errors\n\n**`401`** — `{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.\n\n**`403`** — A publishable key on a route that needs your secret one. The key is real and was authenticated — this is not 401 — so retrying or rotating it changes nothing. Use `aii_sk_…` from your server instead.\n\n**`404`** — No such record, or it belongs to another platform. The two are one response on purpose — a 403 for someone else's id turns this route into an oracle that confirms which ids are real, which is enough to size a competitor from the outside.\n\n**`429`** — Rate limited on the API key. Carries `Retry-After` in seconds. Publishable keys get the tighter budget, because they are the ones a script can lift off a web page.","operationId":"get_session_api_partner_v1_sessions__session_id__get","parameters":[{"name":"session_id","in":"path","required":true,"schema":{"type":"string","title":"Session Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerSessionStatus"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"`{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"403":{"description":"A publishable key on a route that needs your secret one. The key is real and was authenticated — this is not 401 — so retrying or rotating it changes nothing. Use `aii_sk_…` from your server instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"404":{"description":"No such record, or it belongs to another platform. The two are one response on purpose — a 403 for someone else's id turns this route into an oracle that confirms which ids are real, which is enough to size a competitor from the outside.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"429":{"description":"Rate limited on the API key. Carries `Retry-After` in seconds. Publishable keys get the tighter budget, because they are the ones a script can lift off a web page.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}}}}},"/api/partner/v1/interviews":{"get":{"tags":["partner"],"summary":"List Interviews","description":"Your platform's interviews, newest first.\n\n`since` and `until` filter on **creation** time, which is also the sort key,\nso a window and a cursor agree with each other. Worth knowing before you\nbuild an incremental sync on it: an interview that started before your last\n`since` and finished after it keeps its original position, so a sync that\nonly ever moves the window forward will miss its result. Either overlap the\nwindow generously or take results from the webhook, which is ordered by when\nthe result settled rather than by when the interview began.\n\nAn `external_user_id` your platform has never used returns an empty page\nrather than a 404. It is a filter, not a lookup, and \"this learner has no\ninterviews\" is a real answer.\n\nEach entry carries its full result. That is deliberate — the list is what a\ncustomer builds a gradebook import on, and a summary would mean a second\nrequest per interview to get the thing they came for.\n\n### Authentication\n\n**Secret key only** (`aii_sk_…`). Send it as `Authorization: Bearer <key>` or `X-API-Key: <key>`, from your own server. A publishable key (`aii_pk_…`) authenticates fine and is then refused here with a 403: it names or reads a learner, and a credential that ships in page source must not be able to.\n\n### Errors\n\n**`400`** — The `cursor` is not one we issued. Pass `next_cursor` from the previous page verbatim, or omit it to start again. Refused rather than treated as \"no cursor\", because silently restarting would re-import a customer's entire history.\n\n**`401`** — `{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.\n\n**`403`** — A publishable key on a route that needs your secret one. The key is real and was authenticated — this is not 401 — so retrying or rotating it changes nothing. Use `aii_sk_…` from your server instead.\n\n**`422`** — An unknown `status`. The body names the accepted values. Refused rather than returning an empty list, which someone who typed `?status=failure` would read as \"nothing failed\". Also returned for a `limit` outside 1 to 100.\n\n**`429`** — Rate limited on the API key. Carries `Retry-After` in seconds. Publishable keys get the tighter budget, because they are the ones a script can lift off a web page.","operationId":"list_interviews_api_partner_v1_interviews_get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"active | completed | abandoned | failed","title":"Status"},"description":"active | completed | abandoned | failed"},{"name":"since","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Created at or after this instant.","title":"Since"},"description":"Created at or after this instant."},{"name":"until","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Created strictly before this instant.","title":"Until"},"description":"Created strictly before this instant."},{"name":"external_user_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Your own id for one learner.","title":"External User Id"},"description":"Your own id for one learner."},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"`next_cursor` from the previous page.","title":"Cursor"},"description":"`next_cursor` from the previous page."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":25,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerInterviewPage"}}}},"422":{"description":"An unknown `status`. The body names the accepted values. Refused rather than returning an empty list, which someone who typed `?status=failure` would read as \"nothing failed\". Also returned for a `limit` outside 1 to 100.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"400":{"description":"The `cursor` is not one we issued. Pass `next_cursor` from the previous page verbatim, or omit it to start again. Refused rather than treated as \"no cursor\", because silently restarting would re-import a customer's entire history.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"401":{"description":"`{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"403":{"description":"A publishable key on a route that needs your secret one. The key is real and was authenticated — this is not 401 — so retrying or rotating it changes nothing. Use `aii_sk_…` from your server instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"429":{"description":"Rate limited on the API key. Carries `Retry-After` in seconds. Publishable keys get the tighter budget, because they are the ones a script can lift off a web page.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}}}}},"/api/partner/v1/interviews/{interview_id}":{"get":{"tags":["partner"],"summary":"Get Interview","description":"One interview and its full result.\n\nThe body is what your webhook receives, minus the delivery envelope. Feed it\nto the same handler.\n\nNote what is *not* here, on purpose: the internal hiring recommendation\n(`strong_no`…`strong_yes`). It is hiring vocabulary applied to somebody\npractising, and it is not withheld by this route — it is absent from the\nshared serialiser, so no surface can leak it by forgetting to.\n\n### Authentication\n\n**Secret key only** (`aii_sk_…`). Send it as `Authorization: Bearer <key>` or `X-API-Key: <key>`, from your own server. A publishable key (`aii_pk_…`) authenticates fine and is then refused here with a 403: it names or reads a learner, and a credential that ships in page source must not be able to.\n\n### Errors\n\n**`401`** — `{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.\n\n**`403`** — A publishable key on a route that needs your secret one. The key is real and was authenticated — this is not 401 — so retrying or rotating it changes nothing. Use `aii_sk_…` from your server instead.\n\n**`404`** — No such record, or it belongs to another platform. The two are one response on purpose — a 403 for someone else's id turns this route into an oracle that confirms which ids are real, which is enough to size a competitor from the outside.\n\n**`429`** — Rate limited on the API key. Carries `Retry-After` in seconds. Publishable keys get the tighter budget, because they are the ones a script can lift off a web page.","operationId":"get_interview_api_partner_v1_interviews__interview_id__get","parameters":[{"name":"interview_id","in":"path","required":true,"schema":{"type":"string","title":"Interview Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerInterviewResult"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"`{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"403":{"description":"A publishable key on a route that needs your secret one. The key is real and was authenticated — this is not 401 — so retrying or rotating it changes nothing. Use `aii_sk_…` from your server instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"404":{"description":"No such record, or it belongs to another platform. The two are one response on purpose — a 403 for someone else's id turns this route into an oracle that confirms which ids are real, which is enough to size a competitor from the outside.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"429":{"description":"Rate limited on the API key. Carries `Retry-After` in seconds. Publishable keys get the tighter budget, because they are the ones a script can lift off a web page.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}}}}},"/api/partner/v1/interviews/{interview_id}/transcript":{"get":{"tags":["partner"],"summary":"Get Transcript","description":"What was said, in order.\n\nPaged, and the cap is not a performance measure. A transcript is a recording\nof a person talking about their own experience for twenty minutes, and it is\nthe most sensitive thing this API returns — so the default is that a caller\nasks for it a page at a time and takes what they need, rather than that one\nrequest produces a body containing everything a learner said which then sits\nin whatever proxied or logged it.\n\nOffset paging here, where the interview list uses a cursor, and the reason is\nthat turns are only ever appended: a turn's position in the conversation\nnever changes, so the shifting window that makes `OFFSET` unsafe for a\nnewest-first list cannot occur in an oldest-first one. Page 2 of a transcript\nis page 2 whether or not the learner is still talking.\n\nSystem turns are omitted. They are the interviewer's own prompt and rubric,\nnot something anybody said.\n\n### Authentication\n\n**Secret key only** (`aii_sk_…`). Send it as `Authorization: Bearer <key>` or `X-API-Key: <key>`, from your own server. A publishable key (`aii_pk_…`) authenticates fine and is then refused here with a 403: it names or reads a learner, and a credential that ships in page source must not be able to.\n\n### Errors\n\n**`401`** — `{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.\n\n**`403`** — A publishable key on a route that needs your secret one. The key is real and was authenticated — this is not 401 — so retrying or rotating it changes nothing. Use `aii_sk_…` from your server instead.\n\n**`404`** — No such record, or it belongs to another platform. The two are one response on purpose — a 403 for someone else's id turns this route into an oracle that confirms which ids are real, which is enough to size a competitor from the outside.\n\n**`422`** — An `offset` below 0 or a `limit` outside 1 to 500.\n\n**`429`** — Rate limited on the API key. Carries `Retry-After` in seconds. Publishable keys get the tighter budget, because they are the ones a script can lift off a web page.","operationId":"get_transcript_api_partner_v1_interviews__interview_id__transcript_get","parameters":[{"name":"interview_id","in":"path","required":true,"schema":{"type":"string","title":"Interview Id"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":1,"default":200,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerTranscript"}}}},"422":{"description":"An `offset` below 0 or a `limit` outside 1 to 500.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"`{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"403":{"description":"A publishable key on a route that needs your secret one. The key is real and was authenticated — this is not 401 — so retrying or rotating it changes nothing. Use `aii_sk_…` from your server instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"404":{"description":"No such record, or it belongs to another platform. The two are one response on purpose — a 403 for someone else's id turns this route into an oracle that confirms which ids are real, which is enough to size a competitor from the outside.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"429":{"description":"Rate limited on the API key. Carries `Retry-After` in seconds. Publishable keys get the tighter budget, because they are the ones a script can lift off a web page.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}}}}},"/api/partner/v1/usage":{"get":{"tags":["partner"],"summary":"Read Usage","description":"What your platform has used this month, and how close it is to a refusal.\n\nInterviews are counted from the moment one **starts**, whatever became of\nit. An interview the learner abandoned still occupied a room, a worker and\ninference spend, so it counts here exactly as it counts against your quota.\n\nThese are the same numbers the session endpoints check before accepting a\nlaunch, computed the same way over the same month boundary — so if `quota`\nsays `exhausted`, the next `POST /sessions` is the 429 that says so, and if\nit does not, you are not about to be refused on quota grounds.\n\nPoll it as often as you like; it is a handful of counting queries and\nchanges nothing.\n\n### Authentication\n\n**Secret key only** (`aii_sk_…`). Send it as `Authorization: Bearer <key>` or `X-API-Key: <key>`, from your own server. A publishable key (`aii_pk_…`) authenticates fine and is then refused here with a 403: it names or reads a learner, and a credential that ships in page source must not be able to.\n\n### Errors\n\n**`401`** — `{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.\n\n**`403`** — A publishable key on a route that needs your secret one. The key is real and was authenticated — this is not 401 — so retrying or rotating it changes nothing. Use `aii_sk_…` from your server instead.\n\n**`429`** — Rate limited on the API key. Carries `Retry-After` in seconds. Publishable keys get the tighter budget, because they are the ones a script can lift off a web page.","operationId":"read_usage_api_partner_v1_usage_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsageResponse"}}}},"401":{"description":"`{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"403":{"description":"A publishable key on a route that needs your secret one. The key is real and was authenticated — this is not 401 — so retrying or rotating it changes nothing. Use `aii_sk_…` from your server instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"429":{"description":"Rate limited on the API key. Carries `Retry-After` in seconds. Publishable keys get the tighter budget, because they are the ones a script can lift off a web page.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}}}}},"/api/partner/v1/deliveries":{"get":{"tags":["partner"],"summary":"Read Deliveries","description":"Every event we have tried to send you, newest first.\n\nWhere a missing result is explained. A delivery sitting at `pending` with\n\"No webhook URL configured\" means we have nowhere to send it and the row is\nwaiting for you to configure one — nothing is lost. A `failed` one carries\nthe last status code and error your endpoint returned; fix it and retry the\ndelivery rather than asking us to re-run the interview.\n\nThe payload is not included — see the `payload_bytes` field for why, and use\nthe interview read API for the result itself.\n\n`pending`, `delivered` and `failed` in the response count all your\ndeliveries, not the filtered page, so they stay usable as an alert.\n\n### Authentication\n\n**Secret key only** (`aii_sk_…`). Send it as `Authorization: Bearer <key>` or `X-API-Key: <key>`, from your own server. A publishable key (`aii_pk_…`) authenticates fine and is then refused here with a 403: it names or reads a learner, and a credential that ships in page source must not be able to.\n\n### Errors\n\n**`401`** — `{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.\n\n**`403`** — A publishable key on a route that needs your secret one. The key is real and was authenticated — this is not 401 — so retrying or rotating it changes nothing. Use `aii_sk_…` from your server instead.\n\n**`422`** — An unknown `status`. The body names the accepted values (`pending`, `delivered`, `failed`). Also returned for a `limit` outside 1 to 200 or a negative `offset`.\n\n**`429`** — Rate limited on the API key. Carries `Retry-After` in seconds. Publishable keys get the tighter budget, because they are the ones a script can lift off a web page.","operationId":"read_deliveries_api_partner_v1_deliveries_get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter to `pending`, `delivered` or `failed`.","title":"Status"},"description":"Filter to `pending`, `delivered` or `failed`."},{"name":"event_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter to one event type, e.g. `interview.completed`.","title":"Event Type"},"description":"Filter to one event type, e.g. `interview.completed`."},{"name":"interview_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter to one interview. This is the fast answer to 'we never got the result for interview X'.","title":"Interview Id"},"description":"Filter to one interview. This is the fast answer to 'we never got the result for interview X'."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"default":50,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeliveryListResponse"}}}},"422":{"description":"An unknown `status`. The body names the accepted values (`pending`, `delivered`, `failed`). Also returned for a `limit` outside 1 to 200 or a negative `offset`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"`{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"403":{"description":"A publishable key on a route that needs your secret one. The key is real and was authenticated — this is not 401 — so retrying or rotating it changes nothing. Use `aii_sk_…` from your server instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"429":{"description":"Rate limited on the API key. Carries `Retry-After` in seconds. Publishable keys get the tighter budget, because they are the ones a script can lift off a web page.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}}}}},"/api/partner/v1/deliveries/{delivery_id}/retry":{"post":{"tags":["partner"],"summary":"Retry Delivery","description":"Put a delivery back in the queue, once your endpoint is fixed.\n\nFor the case that would otherwise be a support ticket: your receiver was\ndown or returning 500, we exhausted the retry budget and gave up, and the\nresult is now sitting here with nothing scheduled to move it. Fix the\nendpoint, call this, and it goes out on the next cycle — a few seconds — with\na full retry budget rather than one last attempt.\n\nAlso accepted for a `pending` delivery, which pulls a scheduled retry\nforward instead of waiting out the backoff. That is the honest thing to do\nwith \"I have just fixed it, try now\".\n\n**A delivery that already succeeded cannot be retried, and gets a 409.** Your\nendpoint answered 2xx, so the event was accepted; re-sending it is not\nrecovery, it is a duplicate POST of an event you have already acted on. Some\nreceivers deduplicate on `X-AII-Event-Id` and would ignore it, which makes\nthe button a lie; the ones that do not would write the result twice, mail a\nlearner twice, or overwrite a re-scored result with the older snapshot this\nrow froze. If you accepted an event and then lost it, the interview read API\nreturns the current result — which is the better answer anyway, because it\nreflects any re-score since.\n\n404 when the delivery is not yours, identical to the 404 for one that never\nexisted.\n\n### Authentication\n\n**Secret key only** (`aii_sk_…`). Send it as `Authorization: Bearer <key>` or `X-API-Key: <key>`, from your own server. A publishable key (`aii_pk_…`) authenticates fine and is then refused here with a 403: it names or reads a learner, and a credential that ships in page source must not be able to.\n\n### Errors\n\n**`401`** — `{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.\n\n**`403`** — A publishable key on a route that needs your secret one. The key is real and was authenticated — this is not 401 — so retrying or rotating it changes nothing. Use `aii_sk_…` from your server instead.\n\n**`404`** — No such record, or it belongs to another platform. The two are one response on purpose — a 403 for someone else's id turns this route into an oracle that confirms which ids are real, which is enough to size a competitor from the outside. Same response for a delivery id that is not yours.\n\n**`409`** — This event was already delivered and accepted by your endpoint, so re-sending it is a duplicate POST of something you have acted on — a receiver that deduplicates would ignore it and one that does not would double-write your gradebook. Fetch `GET /interviews/{interview_id}` instead; that reflects any re-score, which this frozen snapshot does not.\n\n**`429`** — Rate limited on the API key. Carries `Retry-After` in seconds. Publishable keys get the tighter budget, because they are the ones a script can lift off a web page.","operationId":"retry_delivery_api_partner_v1_deliveries__delivery_id__retry_post","parameters":[{"name":"delivery_id","in":"path","required":true,"schema":{"type":"string","title":"Delivery Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeliveryRetryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"401":{"description":"`{\"detail\": \"Invalid or missing API key\"}`. One response for a key that is missing, unknown, mistyped, revoked, expired, presented with the wrong `live`/`test` segment, presented with the wrong `sk`/`pk` segment, or — for a publishable key — presented from an unregistered `Origin`. They are deliberately indistinguishable: telling them apart would let anyone probe which key ids exist, and you cannot act differently on any of them. Retrying never helps; check the key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"403":{"description":"A publishable key on a route that needs your secret one. The key is real and was authenticated — this is not 401 — so retrying or rotating it changes nothing. Use `aii_sk_…` from your server instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"404":{"description":"No such record, or it belongs to another platform. The two are one response on purpose — a 403 for someone else's id turns this route into an oracle that confirms which ids are real, which is enough to size a competitor from the outside. Same response for a delivery id that is not yours.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"409":{"description":"This event was already delivered and accepted by your endpoint, so re-sending it is a duplicate POST of something you have acted on — a receiver that deduplicates would ignore it and one that does not would double-write your gradebook. Fetch `GET /interviews/{interview_id}` instead; that reflects any re-score, which this frozen snapshot does not.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}},"429":{"description":"Rate limited on the API key. Carries `Retry-After` in seconds. Publishable keys get the tighter budget, because they are the ones a script can lift off a web page.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerError"}}}}}}},"/api/partner/v1/openapi.json":{"get":{"tags":["partner"],"summary":"Partner Openapi","description":"The partner API as an OpenAPI 3.1 document.\n\nGenerated over the partner routes alone. This is **not** the application's\nown `/openapi.json`: that one describes the internal API and the admin\nsurface as well, and publishing a map of routes an outsider has no business\nknowing about is not made acceptable by each of them being authenticated.\n\nUnauthenticated deliberately. A developer designs an integration before they\nhold a credential, and every route described here needs one anyway — so the\ndocument discloses nothing but the shape of calls that will be refused\nwithout a key.\n\nFeed it to a client generator, or paste the URL into a coding agent. The\nrequest and response schemas are the same Pydantic models the routes\nvalidate against, so what it describes is what the server enforces.\n\n### Authentication\n\n**No credential required.** This route describes the contract rather than acting on any data, and a developer needs to read it before they have a key. Every route it describes is authenticated.","operationId":"partner_openapi_api_partner_v1_openapi_json_get","responses":{"200":{"description":"An OpenAPI 3.1 document describing every partner route, and only partner routes.","content":{"application/json":{"schema":{"type":"object"}}}}},"security":[]}},"/api/partner/v1/llms.txt":{"get":{"tags":["partner"],"summary":"Partner Llms Txt","description":"The whole contract as one plain-text document, for an AI coding agent.\n\nFollows the emerging `llms.txt` convention: a single fetch that can go\nstraight into a model's context, rather than a documentation site that has\nto be crawled and reassembled.\n\nThe body is the prose contract served verbatim from the same markdown a\nperson reads — not a second copy maintained alongside it, because two copies\nof a contract is one that is wrong. It carries the reasoning `openapi.json`\ncannot: why a publishable key can only start anonymous sessions, why the\nwebhook signature is computed over the raw body, why `overall_score` can be\nnull, and what the rung levels measure.\n\nPrefixed by a short generated header naming this deployment's base URL, the\ntwo header forms authentication accepts, the two kinds of key, and where the\nOpenAPI document is.\n\nUnauthenticated, for the same reason as `openapi.json`: it is read before a\nkey exists, and everything it describes needs one.\n\n### Authentication\n\n**No credential required.** This route describes the contract rather than acting on any data, and a developer needs to read it before they have a key. Every route it describes is authenticated.","operationId":"partner_llms_txt_api_partner_v1_llms_txt_get","responses":{"200":{"description":"The complete contract as one plain-text document.","content":{"text/plain":{"schema":{"type":"string"}}}}},"security":[]}},"/api/partner/v1/spec":{"get":{"tags":["partner"],"summary":"Partner Spec Index","description":"Where every machine-readable form of this contract lives.\n\nA discovery index, and it is here because an agent is usually handed exactly\none URL. Without it, one that lands on `/spec` — or is told \"the API is at\n/api/partner/v1\" — has to guess at the rest, and a guess that fails silently\nbecomes an integration built from whatever fragment was in the prompt.\n\nStart with `llms_txt_url` if you are about to write the integration; it\ncarries the reasoning. Use `openapi_url` if you are generating a client.\n\nUnauthenticated, like the two documents it points at.\n\n### Authentication\n\n**No credential required.** This route describes the contract rather than acting on any data, and a developer needs to read it before they have a key. Every route it describes is authenticated.","operationId":"partner_spec_index_api_partner_v1_spec_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerSpecIndex"}}}}},"security":[]}}},"components":{"schemas":{"ConcurrencyView":{"properties":{"limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit","description":"Interviews at once. Null means no concurrency ceiling is applied."},"live":{"type":"integer","title":"Live","description":"Running at this instant, whenever they started. This is the number checked when a session is requested."},"available":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Available","description":"Null when there is no ceiling. Never negative."},"at_limit":{"type":"boolean","title":"At Limit","description":"True when the next launch will be refused with 429 for concurrency. It clears by itself as interviews finish."},"peak":{"type":"integer","title":"Peak","description":"The most that were running at once at any moment in this period. The number to size a concurrency ceiling against — `live` read at a quiet moment says nothing about the cohort that ran on Tuesday."},"peak_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Peak At","description":"When that peak was first reached."}},"type":"object","required":["limit","live","available","at_limit","peak"],"title":"ConcurrencyView","description":"Interviews running at once, against the ceiling on them."},"DeliveryListResponse":{"properties":{"deliveries":{"items":{"$ref":"#/components/schemas/DeliveryView"},"type":"array","title":"Deliveries"},"total":{"type":"integer","title":"Total","description":"Matching this filter, not on this page."},"limit":{"type":"integer","title":"Limit"},"offset":{"type":"integer","title":"Offset"},"pending":{"type":"integer","title":"Pending","description":"Across all your deliveries, ignoring any filter — a headline that changed when you filtered would be useless as an alert."},"delivered":{"type":"integer","title":"Delivered"},"failed":{"type":"integer","title":"Failed","description":"Given up on. These are the ones worth retrying."}},"type":"object","required":["deliveries","total","limit","offset","pending","delivered","failed"],"title":"DeliveryListResponse"},"DeliveryRetryResponse":{"properties":{"id":{"type":"string","title":"Id"},"event_id":{"type":"string","title":"Event Id"},"status":{"type":"string","title":"Status"},"attempts":{"type":"integer","title":"Attempts","description":"Reset to zero, so a fixed endpoint gets the full retry budget again rather than one last try."},"next_attempt_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Next Attempt At","description":"Null means the next delivery cycle, which runs every few seconds."}},"type":"object","required":["id","event_id","status","attempts"],"title":"DeliveryRetryResponse","description":"A requeued delivery, as it now stands."},"DeliveryView":{"properties":{"id":{"type":"string","title":"Id"},"event_id":{"type":"string","title":"Event Id","description":"Sent as X-AII-Event-Id. Deduplicate on it: a re-scored interview arrives as a second event for the same interview."},"event_type":{"type":"string","title":"Event Type"},"interview_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Interview Id","description":"Null for events that are not about an interview, such as a test event."},"status":{"type":"string","title":"Status","description":"`pending` — queued or waiting on a retry. `delivered` — your endpoint answered 2xx. `failed` — given up on; retry it once your endpoint is fixed."},"attempts":{"type":"integer","title":"Attempts"},"last_status_code":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Last Status Code","description":"HTTP status your endpoint last returned, if it did."},"last_error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Last Error","description":"Why the last attempt failed. Truncated; the usual reason a delivery is stuck."},"next_attempt_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Next Attempt At","description":"When it will be tried again. Null means either immediately, or never again."},"delivered_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Delivered At"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At","description":"When the event was queued, not when it was sent."},"payload_bytes":{"type":"integer","title":"Payload Bytes","description":"Size of the body that is POSTed to you. The body itself is not returned here: it carries the learner's scores and the rationales behind them, and this endpoint exists to debug delivery, not to read results. Fetch the result from the interview read API, which is the versioned contract for it and reflects a re-score. The size is given because a delivery failing on your side with a 413 is otherwise unexplainable from here."}},"type":"object","required":["id","event_id","event_type","status","attempts","payload_bytes"],"title":"DeliveryView","description":"One outbound event and what happened to it.\n\n**The payload is not here, and that is deliberate.** This endpoint answers\n\"did it arrive\", not \"what did it say\" — see `payload_bytes`."},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"InterviewBreakdown":{"properties":{"completed":{"type":"integer","title":"Completed","description":"Ran to the end and were scored."},"abandoned":{"type":"integer","title":"Abandoned","description":"The learner closed the tab or dropped off and never came back. A high share of these is worth investigating: it usually means microphone permissions or a network problem on your learners' side."},"failed":{"type":"integer","title":"Failed","description":"Ended in an error rather than a hang-up."},"active":{"type":"integer","title":"Active","description":"Started in this period and still running right now."},"other":{"type":"integer","title":"Other","description":"Any lifecycle state added after your integration was written. Normally zero, and present only so the four fields above plus this one always sum to interviews_started.","default":0}},"type":"object","required":["completed","abandoned","failed","active"],"title":"InterviewBreakdown","description":"How this period's interviews ended.\n\nThese sum to `interviews_started`. All of them are billed and all of them\ncount against the quota, including the ones nobody finished — an abandoned\ninterview still consumed a room, a worker and inference spend."},"PartnerAnonymousSessionRequest":{"properties":{"display_name":{"type":"string","maxLength":100,"title":"Display Name","description":"How the interviewer should address them. Ask on the page if you like; a neutral placeholder is used when you do not.","default":"Guest"},"position":{"anyOf":[{"type":"string","maxLength":200},{"type":"null"}],"title":"Position","description":"Role to interview for. Falls back to the tenant's default position when omitted."},"return_url":{"anyOf":[{"type":"string","maxLength":500},{"type":"null"}],"title":"Return Url","description":"Where to send the visitor when the interview ends. Must match one of the tenant's allowed return origins."},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata","description":"Echoed back verbatim on the result callback. Anything you put here was typed by, or is visible to, whoever loaded the page — carry your own page or course ids, not anything you would not publish."}},"type":"object","title":"PartnerAnonymousSessionRequest","description":"A visitor starting an interview from a page with no backend behind it.\n\nThe sibling of `PartnerSessionRequest`, and the whole difference is one\nfield: there is no `external_user_id` here, and no way to supply one. This\nrequest is expected to arrive on a publishable key, which ships in the\ncustomer's HTML and is therefore readable by anyone who views source. A\nfield naming a learner would be a field anyone could fill in with somebody\nelse's id, and they would get that person's history and quota with it.\n\nAbsent for the same reason, on top of the omissions the named request\nalready documents:\n\n* `email` and `email_verified` — an address asserted by a browser is\n  asserted by whoever is holding the browser. Accepting one would hand the\n  account-linking path in `upsert_learner` to the public.\n* `resume_text` and `voice_id` — the visitor supplies both on the setup page\n  they land on, under the same limits every direct candidate gets, rather\n  than through a credential that is public by design."},"PartnerAnonymousSessionResponse":{"properties":{"session_id":{"type":"string","title":"Session Id","description":"Identifies this launch."},"user_id":{"type":"string","title":"User Id","description":"The learner's permanent id here. Stable across launches."},"launch_url":{"type":"string","title":"Launch Url","description":"Redirect the learner's browser here. Single-use and short-lived — do not store it, email it, or render it as a shareable link."},"expires_at":{"type":"string","format":"date-time","title":"Expires At"},"resumed":{"type":"boolean","title":"Resumed","description":"True when the learner already had an interview in progress and this launch rejoins it rather than starting a new one.","default":false},"external_user_id":{"type":"string","title":"External User Id","description":"The subject id minted for this visitor. It appears on the result webhook, so keep it if you want to match that result back to the page view it came from — nothing here can look it up for you afterwards, by design."}},"type":"object","required":["session_id","user_id","launch_url","expires_at","external_user_id"],"title":"PartnerAnonymousSessionResponse","description":"The same launch, plus the id we invented for the visitor.\n\nIdentical to the named response so a customer who later moves to a secret\nkey and real learner ids changes what they send, not what they read."},"PartnerInterviewPage":{"properties":{"interviews":{"items":{"$ref":"#/components/schemas/PartnerInterviewResult"},"type":"array","title":"Interviews"},"has_more":{"type":"boolean","title":"Has More"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor","description":"Pass back as `cursor` for the next page. Opaque — its contents are ours to change. Null when this is the last page."}},"type":"object","required":["interviews","has_more"],"title":"PartnerInterviewPage","description":"One page of interviews, newest first."},"PartnerInterviewResult":{"properties":{"interview_id":{"type":"string","title":"Interview Id"},"session_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Session Id","description":"The launch this interview came from."},"external_user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"External User Id","description":"Your own id for the learner."},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata","description":"Whatever you sent at launch, verbatim."},"status":{"type":"string","title":"Status","description":"`active`, `completed`, `abandoned` or `failed`."},"assessed":{"type":"boolean","title":"Assessed","description":"False when there is no score — either the interview was too short to judge, or scoring has not produced one. Render 'not assessed', never a zero."},"not_assessed_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Not Assessed Reason"},"result":{"additionalProperties":true,"type":"object","title":"Result","description":"The result block from §8: scores, dimensions, summary, rungs and timings. `overall_score` is null whenever `assessed` is false."}},"additionalProperties":true,"type":"object","required":["interview_id","status","assessed","result"],"title":"PartnerInterviewResult","description":"One interview and its result, in the shape §8 already delivers.\n\nIdentical to the webhook body apart from the three fields that describe a\n*delivery* rather than an interview — `event`, `event_id` and `occurred_at`.\nThose are dropped rather than reproduced because a GET is not an event:\nminting a fresh `evt_…` on every poll would put a new value in the field\npartners deduplicate deliveries on, and `occurred_at` would report \"now\" for\nan interview that finished yesterday.\n\n`extra=\"allow\"` is load-bearing. Without it this model becomes a filter, and\na field added to `build_payload` would reach webhook subscribers and vanish\nfor pollers — the exact divergence between the two surfaces that reusing one\nserialiser exists to prevent."},"PartnerSessionRequest":{"properties":{"external_user_id":{"type":"string","maxLength":200,"minLength":1,"title":"External User Id","description":"The learner's stable id on your platform. This — not the email — is what maps a returning learner back to the same record here, so it must not change when they edit their profile."},"display_name":{"type":"string","maxLength":100,"minLength":1,"title":"Display Name","description":"How the interviewer should address them."},"email":{"anyOf":[{"type":"string","maxLength":254},{"type":"null"}],"title":"Email","description":"Optional. Only used for account linking, and only when verified."},"email_verified":{"type":"boolean","title":"Email Verified","description":"Whether your platform has verified this address. An unverified address is never linked to an existing account here — doing so would be an account-takeover path.","default":false},"position":{"anyOf":[{"type":"string","maxLength":200},{"type":"null"}],"title":"Position","description":"Role to interview for. Falls back to the tenant's default position when omitted."},"resume_text":{"anyOf":[{"type":"string","maxLength":100000},{"type":"null"}],"title":"Resume Text","description":"Already-extracted resume text. Optional."},"voice_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Voice Id","description":"Interviewer voice. Falls back to the default when omitted."},"return_url":{"anyOf":[{"type":"string","maxLength":500},{"type":"null"}],"title":"Return Url","description":"Where to send the learner when the interview ends. Must match one of the tenant's allowed return origins."},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata","description":"Echoed back verbatim on the result callback. Use it to carry your own course or module ids rather than storing our interview id."}},"type":"object","required":["external_user_id","display_name"],"title":"PartnerSessionRequest","description":"A partner handing one of its learners over for a practice interview."},"PartnerSessionResponse":{"properties":{"session_id":{"type":"string","title":"Session Id","description":"Identifies this launch."},"user_id":{"type":"string","title":"User Id","description":"The learner's permanent id here. Stable across launches."},"launch_url":{"type":"string","title":"Launch Url","description":"Redirect the learner's browser here. Single-use and short-lived — do not store it, email it, or render it as a shareable link."},"expires_at":{"type":"string","format":"date-time","title":"Expires At"},"resumed":{"type":"boolean","title":"Resumed","description":"True when the learner already had an interview in progress and this launch rejoins it rather than starting a new one.","default":false}},"type":"object","required":["session_id","user_id","launch_url","expires_at"],"title":"PartnerSessionResponse","description":"What the partner backend redirects the learner's browser with."},"PartnerSessionStatus":{"properties":{"session_id":{"type":"string","title":"Session Id"},"state":{"type":"string","title":"State","description":"pending | expired | in_progress | scoring | complete"},"final":{"type":"boolean","title":"Final","description":"True when this answer will not change again. The one field a polling loop should terminate on."},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"},"expires_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Expires At","description":"When the launch URL stopped being redeemable."},"launched_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Launched At","description":"When the learner opened it. Null if they never did."},"interview_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Interview Id"},"external_user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"External User Id"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"},"interview":{"anyOf":[{"$ref":"#/components/schemas/PartnerInterviewResult"},{"type":"null"}],"description":"Null until the learner opens the launch URL. Once present this is byte-for-byte what `GET /interviews/{id}` returns and what your webhook receives, so one function can consume all three."}},"type":"object","required":["session_id","state","final"],"title":"PartnerSessionStatus","description":"What became of one launch.\n\nThe route a customer with no webhook receiver polls, which is why `state`\nand `final` are here and not on the interview: they answer \"should I ask\nagain\", and that question is about the launch, not about the interview it\nmay or may not have produced yet."},"PartnerSpecIndex":{"properties":{"openapi_url":{"type":"string","title":"Openapi Url","description":"OpenAPI 3.1 document covering every partner route, and only partner routes. Unauthenticated."},"llms_txt_url":{"type":"string","title":"Llms Txt Url","description":"The full contract as one plain-text document, following the llms.txt convention. Unauthenticated. Fetch this when you want the reasoning behind the shapes, not just the shapes."},"human_docs_url":{"type":"string","title":"Human Docs Url","description":"The same contract in prose, in the repository, for a reader with access to it. `llms_txt_url` serves the identical text over HTTP for everyone else."},"version":{"type":"string","title":"Version","description":"The contract version, which is the `/v1` in the base URL. The partner API is versioned on its own axis: the internal API can change without this moving.","examples":["v1"]}},"type":"object","required":["openapi_url","llms_txt_url","human_docs_url","version"],"title":"PartnerSpecIndex","description":"Where to find every machine-readable form of this contract.\n\nExists so an agent that was handed one URL — any of the three — can reach\nthe other two without being told they exist. Cheap, and it removes a\nguessing game whose failure mode is an integration built against whatever\nfragment happened to be pasted into the prompt."},"PartnerTranscript":{"properties":{"interview_id":{"type":"string","title":"Interview Id"},"turns":{"items":{"$ref":"#/components/schemas/PartnerTranscriptTurn"},"type":"array","title":"Turns"},"total_turns":{"type":"integer","title":"Total Turns","description":"Across the whole interview, not just this page."},"offset":{"type":"integer","title":"Offset"},"limit":{"type":"integer","title":"Limit"},"has_more":{"type":"boolean","title":"Has More"}},"type":"object","required":["interview_id","turns","total_turns","offset","limit","has_more"],"title":"PartnerTranscript","description":"A page of one interview's turns, in the order they were spoken."},"PartnerTranscriptTurn":{"properties":{"sequence":{"type":"integer","title":"Sequence","description":"Position in the conversation, starting at 1."},"speaker":{"type":"string","title":"Speaker","description":"`interviewer` or `candidate`."},"text":{"type":"string","title":"Text"},"at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"At"}},"type":"object","required":["sequence","speaker","text"],"title":"PartnerTranscriptTurn","description":"One thing that was said."},"QuotaView":{"properties":{"limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Limit","description":"Interviews per period. Null means no quota is applied."},"used":{"type":"integer","title":"Used","description":"Interviews started in this period. The same number the launch endpoints check before accepting a session."},"remaining":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Remaining","description":"Null when there is no quota. Never negative."},"exhausted":{"type":"boolean","title":"Exhausted","description":"True when the next launch will be refused with 429 on quota grounds. Never true when there is no quota."}},"type":"object","required":["limit","used","remaining","exhausted"],"title":"QuotaView","description":"Monthly interview allowance, and how much of it is gone."},"UsagePeriod":{"properties":{"start":{"type":"string","format":"date-time","title":"Start","description":"First instant counted, inclusive."},"end":{"type":"string","format":"date-time","title":"End","description":"First instant of the next period, exclusive. This is also when the monthly quota resets."}},"type":"object","required":["start","end"],"title":"UsagePeriod","description":"The window a usage figure is counted over.\n\nCalendar month in UTC, because that is the window `monthly_interview_quota`\nis enforced over. Returned rather than assumed so a customer in another\ntimezone can reconcile our count with theirs instead of filing a bug about\nthe two disagreeing at the month boundary."},"UsageResponse":{"properties":{"period":{"$ref":"#/components/schemas/UsagePeriod"},"interviews_started":{"type":"integer","title":"Interviews Started","description":"Interviews started in this period, whatever became of them. This is the billable and quota-bearing count."},"interviews":{"$ref":"#/components/schemas/InterviewBreakdown"},"learners_active":{"type":"integer","title":"Learners Active","description":"Distinct learners who started at least one interview in this period."},"learners_total":{"type":"integer","title":"Learners Total","description":"Distinct learners ever handed over from your platform. Note that every anonymous session is a new subject by design, so this counts page views rather than people if you use publishable keys."},"interviews_all_time":{"type":"integer","title":"Interviews All Time","description":"Every interview your platform has ever started."},"quota":{"$ref":"#/components/schemas/QuotaView"},"minutes":{"$ref":"#/components/schemas/QuotaView"},"concurrency":{"$ref":"#/components/schemas/ConcurrencyView"}},"type":"object","required":["period","interviews_started","interviews","learners_active","learners_total","interviews_all_time","quota","minutes","concurrency"],"title":"UsageResponse","description":"Everything needed to answer \"how much have we used, and are we near a wall\"."},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"PartnerError":{"description":"The body of every refusal from this API.\n\nOne shape for 400, 401, 403, 404, 409 and 429 — FastAPI's `HTTPException`\nenvelope. Documented as a real model because an error body a caller has to\nguess at is one they will parse wrongly, and the first time they find out is\nin production while trying to read why they were refused.\n\nThe 422 from request validation is the exception: it carries FastAPI's\nstructured `HTTPValidationError` list instead. A few routes also return 422\nwith this simpler shape — each one says so in its own description.","properties":{"detail":{"description":"Human-readable reason, safe to log. Never a stack trace and never a distinguishing message for an authentication failure.","examples":["Invalid or missing API key"],"title":"Detail","type":"string"}},"required":["detail"],"title":"PartnerError","type":"object"}},"securitySchemes":{"BearerApiKey":{"type":"http","scheme":"bearer","description":"The documented form: `Authorization: Bearer aii_sk_live_<key_id>_<secret>`. The whole key string is the bearer token — it is not a JWT and there is nothing to decode."},"ApiKeyHeader":{"type":"apiKey","in":"header","name":"X-API-Key","description":"The same key in a header some HTTP clients reach for first. Exactly equivalent to `Authorization: Bearer`; send one or the other, not both."}}},"security":[{"BearerApiKey":[]},{"ApiKeyHeader":[]}]}