August 31, 2026
New Features
-
Launch a run for later:
POST /runs/{id}/launch now takes an optional scheduledAt. The run moves to SCHEDULED, dispatches nothing, and starts on its own at that time — up to 30 days ahead, the same ceiling a single conversation’s scheduleTime has always had.
affectedCalls is 0 because nothing was queued. Omit the body entirely and launch behaves exactly as it did — starting now is still the common case, and nothing about it changed.
It fires through the same code an immediate launch runs, so a run that starts on Monday morning is indistinguishable from one somebody launched by hand on Monday morning. Expect it up to a minute late: a scheduled start is an appointment, not a deadline.
-
A booking is not a reservation, and
scheduleError is how you find out: the batch is admitted against your billing when you schedule it and again when it fires, and the second one is the one that decides. Nothing is held in between, so a balance that moved can leave a booked run unable to start.
A run refused at its time stays SCHEDULED and is retried, with the reason in a new scheduleError field on GET /runs/{id} (alongside a new scheduledAt, both null on a run that launched immediately). Topping up is enough to make it go; there is nothing to re-book.
Reserving instead was rejected: holding minutes for up to 30 days against a run that may be cancelled is a different promise about your balance than this product makes anywhere else.
scheduleError is the only place a failed start is visible. A run whose scheduledAt has
passed while its status is still SCHEDULED has been refused, and nothing else will tell you.
If you book runs, poll it.
Attaching to a scheduled run holds the contact rather than dialling them. A booked run has not started, so a contact added on Saturday to a run booked for Monday joins the list and goes out with everyone else — queued comes back 0. This is the one place SCHEDULED does not behave like RUNNING: dispatching there would call that person days early, and they would be the only person called.
Cancelling a scheduled run clears the appointment, so a cancelled run never starts. No new scope: scheduling is write:runs, the same as launching.
August 30, 2026
New Features
-
Runs are writable: create, attach, launch, pause, resume, cancel, delete: the read surface shipped on 29 August; this is the other half, and it completes the API’s move beyond one-call-at-a-time. A run is one agent working through a list of contacts, and building one is three requests — none of which can call anybody twice:
-
Create takes no contacts, and that is the safety feature: if it did, a request that timed out after we had committed would leave you unable to tell whether it worked. Retrying would produce a second draft holding the same list — dialling nothing, but sitting there, launchable. An orphan holding 500 real contacts is exactly the thing somebody finds later and launches “to be safe”, and then every one of those people is called twice.
A retried create leaves an empty draft instead. It holds nothing, so there is nothing to launch by mistake, and attach is idempotent so the second request is safe to repeat too. This is also why there is no
Idempotency-Key header on this API — the one endpoint that would have needed one does not, once create is empty.
-
A launch means queued, not dialled: a
2xx says the conversations are on the dispatch queue, not that anybody has been called. Per-contact outcomes arrive as conversation.* webhooks, each carrying runId, rather than in the response.
All-or-nothing. The whole contact list is admitted against your billing before any of it is dispatched. A run you cannot afford is refused whole with a 402 carrying what the batch needed and what was available, nothing is queued, and the run stays a draft you can launch once you have topped up. Partial admission — dialling as far as the balance goes — was deliberately rejected: it turns one request into an outcome nobody can predict or undo, because you cannot un-call the contacts who already went out.
Worth stating plainly: your rate limits do not bound this. Per-key and per-company limits bound requests, not the calls one request fans out into. Admission is what bounds dialling; the rate limiter never was.
-
Attach works mid-run, which is the point: contacts can be attached in every status except
CANCELLED. On an already-launched run they are dispatched immediately; on a paused run they wait for the resume; and attaching to a COMPLETED run reopens it to RUNNING. That is what makes a run a rolling sequence rather than a frozen batch.
The counts report rows actually changed, so a repeated or overlapping batch tells you honestly that it did nothing. Up to 500 ids per request; more is a 400 rather than a silent truncation, because a caller told “attached 500” out of 900 has no way to know which 400 were dropped. Chunk it — that is safe by construction.
If your integration stops polling on COMPLETED, it will miss anything attached afterwards.
-
Delete is refused unless the run is inert: allowed from
DRAFT, COMPLETED and CANCELLED; a SCHEDULED, RUNNING or PAUSED run answers 422 with "Cancel the flow run before deleting it". Letting DELETE cascade a cancel was rejected: it makes an HTTP verb silently terminate live phone calls.
The conversations a deleted run produced survive — separate resources, still readable at /conversations/{id} with runId populated. Deleting removes the grouping, not the history. Deleting an already-deleted run is a 404, not an error state to handle.
-
Two new scopes,
write:runs and delete:runs: no legacy aliases, and your existing keys were granted them alongside read:runs. delete:runs is separate deliberately, so a key that may build and drive a run need not also be able to remove it. Two operations need a second scope because they reach data that is not the run: POST /runs/{id}/contacts needs read:contacts, and GET /runs/{id}/conversations needs read:conversations. See Scopes and Permissions.
August 29, 2026
Improvements
-
The Article 50 disclosure is now native in all 32 languages, and no longer doubles up: the sentence appended to a
firstMessage that does not disclose was verified in Slovak, Czech and English, and fell back to the English wording for the other 29. All 32 are now signed off, so a call in Hungarian gets AI hangasszisztens vagyok. rather than an English sentence in the middle of it. The full table is in the agents guide.
The same change fixes a duplicate. Our own generated openings introduce the agent as an AI recruiter in every language, and the check that decides whether a disclosure is needed did not recognise 14 of those introductions: German, Polish, Dutch, Swedish, Danish, Norwegian, Croatian, Romanian, Hungarian, Hindi, Vietnamese, Indonesian, Malay and Tamil calls opened by disclosing twice.
Nothing changes for an agent whose firstMessage you wrote yourself and which already discloses, and nothing changes for Slovak, Czech or English.
New Features
-
Runs are readable over the API: a run is one agent working through a list of contacts — the batch primitive behind the dashboard’s campaigns — and it had no public surface at all. Two endpoints open it up:
A run does not replace the conversation, it batches it: launching mints one conversation per contact, and the call attempts hang off that conversation exactly as they do for one you created yourself. So the drill-down returns ordinary conversation resources, with the same analysis, analytics and call attempts
GET /conversations returns, and everything you already handle for a conversation keeps working unchanged.
The aggregate is on the run itself:
Every conversation lands in exactly one bucket, so the six counts sum to total. inProgress counts calls ringing or connected right now rather than reading the conversation’s stored status, which stays SCHEDULED while a call is being placed. unreachable is its own bucket rather than part of failed, because nothing malfunctioned — the retry budget was spent without the contact ever picking up.
See Get Run, List Run Conversations and the Runs guide.
-
A conversation says which run produced it, in the response and in the webhook: every conversation now carries a nullable
runId — null for one created on its own, the run’s id for one a run produced.
Every conversation-anchored webhook payload carries it too, beside the ids it already sends, so a handler attributes a call to its batch without reading the conversation back at all:
Additive on both surfaces, and like the other payload ids it does not depend on the webhook’s vocabulary: the key is new, always present, null where there is no run, and nothing else about the conversation or the payload changed. See Webhooks.
-
New scope
read:runs, and nothing for you to do: runs had no public surface before, so unlike conversations and contacts there is nothing to stay compatible with — one name, no legacy spelling. Your existing keys were granted it, so they read runs today without being touched, and keys created from now on are issued with it. GET /runs/{id}/conversations needs read:conversations as well, because its rows are conversation resources rather than a run-shaped summary; read:interviews satisfies that half as it does everywhere else. See Scopes and Permissions.
Read only, deliberately. Creating a run, attaching contacts to it, launching, pausing and cancelling stay in the dashboard for now. A launch is the one request in the API that can fan out into hundreds of billable calls, and the write surface is not shipping ahead of the admission and retry-safety work that deserves. Nothing in these two endpoints places a call.
COMPLETED is not terminal. Attaching a contact to a finished run reopens it to RUNNING, which is what makes a run usable as a rolling sequence rather than a one-shot batch. If your integration stops polling on COMPLETED, it will miss anything added afterwards.
Behavior Changes
-
A billing refusal is now
402, not 403, and it tells you the size of the gap: running out of minutes or credits, and hitting your monthly spending cap, used to answer 403 — the same code as a missing scope or another company’s resource. Switching on the status could not tell “top up” from “you may not do this at all”. Those refusals are now 402, which is the code this API already used for a missing subscription and which the error reference already described as the billing one.
The rule is now clean: 402 is money, 403 is permissions.
The billing object is new and is the point of the change: the numbers used to exist only inside the message, where a human could read them and your code could not. requiredMinutes minus availableMinutes is the top-up you need to run the batch as it stands — to split it instead, size the next batch against availableMinutes, not against that difference. A credits-based company gets "billingSystem": "credits" with requiredCredits and availableCredits, matching the split GET /billing/usage already returns — read billingSystem first rather than assuming minutes. billing is absent when there are no figures to report, so treat a missing object as unknown, never as zero.
What to check in your integration: anything that branches on 403 to mean “out of balance”. It will now see 402. Everything that already treated 402 as a billing stop keeps working and simply covers more cases. 403 still means what it always meant for scopes and cross-company access, and no message text changed.
August 28, 2026
Behavior Changes
-
A conversation’s status now tracks its calls, in both directions: a conversation whose next attempt had been queued after a failed call — by our retry, or by a reschedule — kept reading
IN_PROGRESS, FAILED or UNREACHABLE until that call connected, and one that had left SCHEDULED once could never show IN_PROGRESS again. status now follows the call attempts underneath it:
Between two attempts of a retried conversation you therefore see SCHEDULED and the next scheduledAt, where it used to sit on IN_PROGRESS for the whole backoff with no call running.
-
A terminal status is now terminal until the conversation is reopened: rescheduling a conversation that had settled on
FAILED, UNREACHABLE, CANCELLED or COMPLETED returns it to SCHEDULED, clears finishedDate, and it runs to an outcome as normal under the same id. Earlier transcripts, recordings and analysis are kept — they belong to the attempts that produced them. This replaces the previous guarantee that a terminal status never changes again. Nothing is reopened on its own — only a reschedule, a retry, or an explicitly invoked attempt does it.
What to check in your integration: anything treating IN_PROGRESS as “a call is happening right now” was already unreliable and simply gets more accurate, with no change on your side. Two things do want a look:
- Anything that assumes
status only moves forward. IN_PROGRESS → SCHEDULED is now an ordinary transition between retries, where the status previously just stuck.
- Anything that stops polling on a terminal status. Subscribe to
conversation.rescheduled (interview.rescheduled on a webhook still set to "vocabulary": "LEGACY") and resume when it fires. Transcripts, recordings and analysis from earlier attempts belong to the attempts that produced them and are unaffected by a reopen.
See Interview Status for the full table.
August 27, 2026
Documentation
-
The reference now reads in the nouns the API uses:
/conversations and /contacts arrived on 21 August, and every reference page has carried a callout since saying so — over a title, a URL and a body that still said “interview” and “candidate”. The pages have moved:
Every old URL redirects to its new one, so a link in your own runbook keeps working. The sidebar groups are renamed with them, and the deprecation table gains a row for the webhook event names and a second table for the names that deliberately did not change — candidatePosition, the contact status values, interviewCount, totalInterviews and the interview_minutes cost key are the names the API accepts and returns, so they are unchanged and have no alias to look for.
jobs, agents, analysis, sourcing and enrich are unchanged, as they have been throughout: none of those nouns was ever HR-specific.
Behavior Changes
-
Idempotency-Key is not a header we read, and the guide has stopped saying otherwise: Error Handling documented an Idempotency-Key header with a worked example and the line “Safe to retry with same idempotency key”. No endpoint has ever read that header. Sending it has no effect and never had one — the request is processed as if it were absent.
Nothing changed on our side today; what changed is that the documentation now describes what happens. Retries are not deduplicated for you, and the case that matters is POST: retry a create after a timeout and you get two resources, which for a conversation means a second call to the same person.
What to check in your integration: any retry that sends Idempotency-Key and treats it as protection. Read before you retry a POST — a timeout does not tell you whether the write landed — and keep your own identifier in metadata so you can find out. The guide has the pattern.
-
Three deletes cascade further than the documentation said: these are long-standing behaviours that were documented backwards, not changes made today. Each page now carries a warning.
The previous text said the opposite in each case, most explicitly on jobs: “deleting a job does not affect existing candidates or interviews. Those candidates and interviews retain their association with the job for historical accuracy.” None of it survives the delete, and none of it is readable through the API afterwards.
What to check in your integration: anything that deletes a job or a contact as cleanup. To retire a job without losing anything, set its
status to CLOSED instead. A contact assigned to several jobs is still deleted by a delete of any one of them — the cascade runs by job, not by “contacts left with no job”.
Bug Fixes
-
The documented error body is now the one you actually receive: the error sections of Error Handling, the resource guides and the webhook reference pages showed a nested envelope with a machine-readable code:
No endpoint has ever returned that. A 4xx or 5xx answers with the flat body the top of the same page already described —
statusCode, message, traceId, timestamp — plus an errors array on a rejected request body, one entry per rejected constraint. There is no code field and no details object.
What to check in your integration: anything that branches on error.code, or reads error.details. Switch on statusCode instead, and read errors for the field-level detail. The worked handlers have been rewritten accordingly, and every example error now carries a message the API really sends — a rejected body, for instance, always says "Validation failed", with the specifics in errors.
-
contactId is optional on GET /conversations: the spec and the reference page both said it was required “for MVP”, so a listing was documented as always being the conversations of one contact. It never was — contactId, agentId, jobId and status are all optional filters, and omitting them lists the company’s conversations. status takes the upper-case values (COMPLETED, not completed); the example that showed a lower-case one would have been rejected before the listing ran.
-
Naming a
jobId on a conversation assigns the contact to that job: POST /conversations documented jobId as needing to be one of the jobs the contact already held. It only has to belong to your company: if the contact is not assigned to it, creating the conversation assigns them. The published spec already described this; the guide and the field description did not.
-
Corrections to documented values that were never real:
GET /contacts?status=INTERVIEWING was shown as an example filter and INTERVIEWED, HIRED and WITHDRAWN as part of a status workflow; the five values the API accepts are UNDEFINED, APPLIED, IN_PROCESS, REJECTED and ACCEPTED. The quickstart described a { success, data, error, timestamp } response envelope that has never existed, and its Python examples read the removed { statusCode, message, data, traceId } wrapper and checked for 200 where a create answers 201. analysisCount, interviewCount and links are documented as declared but not populated, because nothing populates them.
August 25, 2026
Behavior Changes
-
A conversation we could not score comes back with no rating rather than a made-up one: when the model that scores a conversation could not be reached, the analysis used to fall back to placeholder component scores — two of which defaulted to the top of the range — and publish an
overallRating computed from them. The number looked like an assessment and read plausibly, and nothing in the response distinguished it from one. Those fallbacks are gone.
analysis.general.overallRating is now null on such a run, and a new scoringIncompleteReason says why:
model_unavailable means the model could not be reached and we have queued the analysis to run again, spaced over roughly seven minutes; when it recovers the rating appears, the reason disappears, and analysis.completed fires again. model_error means it answered unusably and nothing further happens on its own.
What to check in your integration: anything that reads overallRating as a number, and in particular anything that coerces a missing one to 0. A null rating is not a low rating, and ranking or rejecting on it would rank a candidate we did not score below one we scored badly. Branch on scoringIncompleteReason instead. See Analysis Structure.
The rest of the analysis is untouched: strongPoints, weakPoints, evaluation and the type-specific specific data come from different calls and arrive as usual. The conversation itself stays COMPLETED — a vendor outage is not the contact’s fault and does not cost them their interview.
New Features
-
Conversation webhooks, in nouns that are not about hiring: the events that fire for a call now have a neutral name alongside their HR one, and each webhook chooses which it receives:
analysis.*, sourcing.*, enrich.* and ping are unchanged — those nouns were never HR-specific.
-
Nothing you have registered changes, and there is no migration: every webhook that existed before today was set to
"vocabulary": "LEGACY" and keeps receiving interview.* exactly as it always has, with no deadline attached. Webhooks created from now on default to "NEUTRAL". Switch whenever you are ready:
-
You receive one name per event, never both: sending each event twice under two names would be worse than either name on its own — a consumer switching on the event string would process every call twice and never see an error. A switch applies to events triggered after it, and a delivery already queued finishes under the name it started with, across every retry attempt. So no single trigger is ever delivered under two different names, and switching mid-flight cannot make a deduplicating consumer see one event as two.
-
Registration takes either spelling too: the
events array now accepts "CONVERSATION_COMPLETED" alongside "INTERVIEW_COMPLETED", and stores them identically — so a new integration never has to write a noun the reference no longer teaches, and an existing one never has to change. Registering an event under both spellings subscribes you once, not twice. Which name you then receive is decided by vocabulary, independently of how you registered.
-
Payload ids now carry both names, always identical: conversation payloads gained
conversationId beside interviewId and contactId beside candidateId, with the same values — the same additive treatment the REST resources got on 21 August. Unlike the event label, the payload body does not depend on the webhook’s vocabulary: both spellings are always present, so there is only ever one payload shape to handle.
-
Scopes in the same nouns as the resources they open:
/conversations and /contacts arrived on 21 August with the scope names left behind — a read of /conversations still needed read:interviews. The scopes now match the resources:
-
Either name works, in both directions, and there is nothing to migrate: the two spellings are the same permission. A key holding
read:interviews reads /conversations and /interviews alike; a key holding read:conversations reads both too. Your existing key was not touched, keeps the scopes it already has, and has no deadline to move — the HR names stay valid for the life of v1. jobs, agents, companies, billing and webhooks scopes are unchanged.
-
New keys are issued with the new names: a key created from now on gets
read:conversations rather than read:interviews, and the dashboard’s scope list offers the current names only. Keys you already have keep displaying the names they were created with.
-
A
403 now names both spellings: Insufficient permissions. Required scopes: read:conversations (or read:interviews). If you hold neither, the message tells you which scope to add without you having to work out which vocabulary your key was minted in.
Behavior Changes
-
Every call now opens by saying it is an AI: if an agent’s
flow.firstMessage does not already disclose that the caller is an AI system, one short sentence is appended to it at call time, in the agent’s own language. Article 50 of the EU AI Act puts that duty on us as the provider of the system, not on you as the one configuring it, so it could not be left to each agent’s wording. See the first message always discloses the AI for the exact sentences and for what counts as an existing disclosure.
Openings generated by the platform have always disclosed, so agents you have not hand-edited are unaffected. Nothing is appended when your own wording already discloses, and writing your own disclosure into firstMessage remains the way to control the exact phrasing — which is what we recommend for languages other than Slovak, Czech and English, where ours falls back to the English sentence.
Your stored firstMessage is not rewritten. The sentence is added on the way to the call, and reading the agent back returns exactly what you sent.
-
A time you name is the time we call:
scheduleTime on POST /conversations, and scheduledAt on both call-attempt routes, are now used exactly as sent, including when they fall outside the agent’s calling window. Previously such a time was quietly moved forward to the next moment the window was open, so an appointment your contact had agreed to could be answered by a call three hours later than promised.
The window has not gone anywhere. It still governs every time we choose: a conversation created with no scheduleTime, and every retry after a call that did not connect. What changed is that it no longer overrules a time you told us, on the reasoning that you know things about the contact that a set of hours cannot.
If you would rather the window always won, keep sending times inside it, or omit scheduleTime and let us place the call.
-
The same instant now gets the same answer, however you write it: whether a scheduled time was inside the calling window used to be decided on whichever clock the timestamp’s offset implied, rather than on the contact’s.
2026-08-25T07:00:00Z and 2026-08-25T09:00:00+02:00 are the same moment, and they got opposite verdicts: one was left alone, the other pushed two hours. Both spellings now behave identically, and an offset does only what an offset is for, which is pinning the instant.
This also means a contact anchor is honoured in the case that motivated it. A recruiter in Adelaide booking 07:00 their time for a contact in Bratislava used to have the agent’s hours read on the Adelaide clock, which could place the call at 00:30 local for the person being rung.
-
language on a transcript segment is always the documented BCP-47 form now, one spelling per language: the field is documented as BCP-47 ("en", "sk"), but a segment could also come back carrying the code our transcription provider reports — "eng" for the same English, "slk" for the same Slovak. Two spellings for one language meant a segment.language === "en" check passed on some conversations and failed on others, and grouping a transcript by language counted each one twice.
This applies everywhere we hand you a transcript segment: GET /interviews/{id} and the segments embedded in the analysis webhook payload. If you match on this field in a webhook consumer, check it there too.
Expect most segments to change rather than a few. The provider spelling was the common case, not the exception, so a language you have only ever seen as "slk" will now arrive as "sk" on every conversation, historical ones included. Values already in the documented form are untouched, and a regional variant such as "en-US" is reported as "en".
Match on the exact code rather than on length: most codes are two letters, but Filipino is "fil", as the published schema’s ^[A-Za-z]{2,3} pattern allows.
Older conversations read back the same way, so there is nothing to migrate and no window where the two forms coexist. The published schema is unchanged: it always said BCP-47, and this is the implementation catching up to it.
August 24, 2026
Behavior Changes
-
The
delay section type is gone from the schema: it was reserved for a timed pause and never built, so sending one has always been rejected. What changes is only which rejection you get. Where a delay section used to come back as a standalone 422 saying the type was not available yet, it now comes back as UNKNOWN_BLOCK_TYPE in the errors array, reported alongside every other fault in the same document instead of on its own.
Nothing that works today stops working: no agent could ever store one, so no stored flow contains one. If you assert on the “not available yet” message in tests, that message no longer has a type that triggers it. It stays in the API for whenever a future type is added to the schema before it works, and the alternatives it names are still generated from what is actually built.
Why dropped rather than built: a pause is not something a prompt can enforce. The compiler could only write the instruction into the agent’s prompt and hope, with no timer behind it, which is precisely the kind of block that reads as a feature and behaves as nothing. Building it needs a mechanism in the call itself, and that work is worth doing when the need is real rather than to retire a reserved name.
Documentation
- The section-type reference no longer reserves a fifth type: Create Agent now says the four documented types are the whole set, and explains what a value outside them returns. The
422 example that had no errors array was rewritten around a size-limit rejection, which is a case you can still reach.
August 21, 2026
New Features
-
/conversations and /contacts: the API in nouns that are not about hiring: InstaView started in recruitment, so its API was written in recruitment words. Agents now run sales, support and operations calls, where “interview” and “candidate” describe the wrong thing — a renewal check-in is not an interview, and the person on the other end is not applying for anything. Both resources now answer to a name that does not assume hiring:
Every route under both resources moved together: /conversations/{id}/call-attempts, /conversations/{id}/analysis-pdf, /contacts/{id}, and the rest. The field follows the resource — a conversation now returns contactId — and create and list accept contactId and the inline contact body wherever they took candidateId and candidate.
We did not cut a v2 for this. A major version whose only content is a rename costs you a migration and buys you nothing, and it spends the one clean break we get on cosmetics.
-
callConfig: how persistently an agent calls, and when — in the contact’s own timezone: a custom agent’s retry policy and calling window are now readable and writable on POST /agents, PATCH /agents/{id}, and the inline agent on POST /conversations. Both halves are honoured by dispatch.
timezoneAnchor is the field that decides what those hours mean. With "mode": "contact" the timezone is detected from each contact’s phone number, down to the area code — not from their country. On one list, +1 212 is called at 09:00 in New York, +1 310 at 09:00 in Los Angeles, and +1 602 at 09:00 in Phoenix, which does not observe daylight saving at all. The same resolution applies inside every country that spans zones: +7 4212 is Vladivostok, not Moscow; +55 92 is Manaus, not São Paulo; +52 664 is Tijuana, not Mexico City. With "mode": "fixed" every contact is called on one named IANA zone instead, for when the hours belong to your operation rather than to the person being called.
Some prefixes genuinely cover more than one zone — an Australian +61 8 number could be Adelaide or Perth, up to two hours apart, and the number cannot tell you which. The agent then waits until your hours are correct in every candidate zone rather than picking one, so an uncertain contact is called slightly later and never two hours too early. Retries land inside the same window, so nobody is called back outside the hours you set.
-
Timezones now resolve worldwide, and below country level: calls were already placed in the contact’s own timezone, but the tables behind that covered about 65 countries and mapped every
+1 number to New York regardless of area code — so a Los Angeles number was called on Eastern time. Resolution is now driven by Google libphonenumber’s prefix data, which covers all 245 regions it supports and distinguishes area codes. Nothing to change on your side, and existing agents get the more accurate answer too.
-
The composer can be asked for a calling window in words: “only call weekday mornings, and try twice” now comes back on the preview as
callConfig, in the shape POST /agents accepts — so it survives whether you post the preview back or just the composerSessionId. Sending callConfig in your create call still wins over whatever the composer chose.
Behavior Changes
-
Nothing you have written stops working, and the old names are not going away:
/interviews and /candidates are served by the same handlers as their new names — not a redirect, not a copy that can drift. Same request, same response, same scopes, same rate limits, same webhooks. They stay for the life of v1.
-
Responses now carry the contact id under both names: a conversation returns
contactId and candidateId with the same value, always. This is additive: if you read candidateId today, you will keep reading it, and you can move field by field at your own pace rather than in one cut-over.
-
Sending both id names with different values is now a
400: {"contactId": "A", "candidateId": "B"} is rejected rather than resolved by a rule you cannot see. There is no defensible way to pick a winner, and guessing would place a call to a contact you did not name. Sending both with the same value is fine, as is sending either on its own. The same applies to the inline contact / candidate body on POST /conversations: send one, not two.
-
Scopes are unchanged: each operation keeps the scope of its legacy twin, whichever name you call it by. So
/conversations reads need read:interviews, writes need write:interviews and deletes need delete:interviews; /contacts uses the …:candidates set the same way. Scope strings live on your API key, so renaming them is a separate change with its own compatibility path. Nothing about your key changes today, and you do not need to do anything when it does.
-
Two schema names changed, which affects generated clients only:
PublicCandidateAnalysisDto is now PublicConversationAnalysisDto and CandidateDto is now ContactDto in openapi.json. The JSON on the wire is byte-for-byte the same — only the type names an SDK generator produces from the spec differ. Hand-written clients are unaffected. The legacy path operations also carry a _legacyPath suffix on their operationId so the two spellings do not collide in a generated client.
-
Nothing changes for agents that already exist: an agent created before calling windows were configurable reports
"callWindow": { "mode": "business_hours" } and keeps dialling exactly as it always has — Mon–Fri, 08:00–20:00, in the contact’s own timezone. Those were already its hours; they lived on the phone number rather than on the agent, and every number was created with that schedule. No stored agent was rewritten and no dialling schedule moved. Sending a callWindow is how such an agent is moved onto a real one, and that is the only thing that changes it.
-
business_hours cannot be selected: it is a legacy preset holding those agents at the behaviour they already had, and its timezone anchor is fixed to the contact. New agents omitting callConfig get Mon–Fri 09:00–18:00 in each contact’s own timezone, three attempts each — a slightly narrower day than the preset’s 08:00–20:00, which is the one difference to know if you compare a new agent against an old one.
-
"callConfig": null on update is rejected, and the object is merged rather than replaced: this is the opposite of guardrails, contextConfig and analyticsConfig, and both differences are deliberate. Every agent dials on some schedule, so there is no “no policy” state to clear to — the only thing clearing could mean is falling back to the legacy business_hours preset. And retryPolicy and callWindow are each replaced when sent and kept when omitted, so changing the attempt count does not mean restating the window. A callWindow you do send is replaced whole.
-
Sending only
retryPolicy on create gets the platform default window: {"callConfig": {"retryPolicy": {"maxAttempts": 2}}} on POST /agents gives the new agent two attempts and Mon–Fri 09:00–18:00 in each contact’s own timezone — the same window it would have had with no callConfig at all. The equivalent call on PATCH /agents/{id} still leaves the stored window alone, including a business_hours one, because there the window is history worth preserving and on create there is none.
-
A calling window with no valid moment dials anyway: requiring the hours to hold in every candidate timezone means a narrow window over a wide prefix can have no shared instant —
09:00–10:00 is never true in both Adelaide and Perth at once. The contact is called rather than left uncalled indefinitely, and the call is recorded as having gone out outside the window. Widening the window, or pinning the anchor to fixed, avoids it.
-
A callback the contact asked for still overrides the window; one they did not now respects it: when someone says “call me at 2pm” the agent calls at 2pm on their clock, whatever the calling window says — a time the contact chose outranks the schedule, and that has not changed. What changed is the other half. When the contact asks to be called back but names no time we can use, that slot now comes from the agent’s calling window instead of a randomized 06:00–18:00 UTC one, so a callback nobody specified can no longer land in the middle of their night. The
candidateRequest.timeResolved flag on interview.rescheduled still tells the two cases apart.
-
Calling hours have moved from the phone number to the agent: they used to be set per phone number, under Settings → Phone Numbers → Call Settings, and that screen is gone along with the
/company-phone-numbers/{id}/call-settings endpoints. They were never part of the public API. The agent’s callConfig replaces them and says more: the window is anchored explicitly, and the contact’s timezone resolves down to the area code. Windows you had set by hand are not carried over — the defaults every number was created with are what business_hours now reproduces, so unless you had edited a number’s hours, nothing changes.
-
An invalid timezone is rejected on the request: zone names are checked against the runtime’s own timezone database, so
"Europe/Atlantis" is a 422 when you send it rather than a schedule that quietly never fires. The same applies to a callWindow.end earlier in the day than its start, which is refused rather than reinterpreted as wrapping past midnight.
Documentation
-
Resource names explains the pair, with the full alias table and what is and is not renamed. Every affected reference page now documents the current name and notes its permanent alias, and the
/jobs and /agents resources are called out as unchanged — agent is already neutral, and job has no better generic equivalent and is optional on a conversation anyway.
-
Retries and calling hours documents the fields, both timezone anchors, the multi-zone prefix behaviour, and what a
business_hours agent means. Update Agent covers the merge-and-not-nullable semantics.
August 19, 2026
New Features
-
Design an agent by describing it:
POST /agents/composer takes a plain-language brief and returns the agent InstaView would build from it — the whole conversation, its persona, and what to extract and score after every call. Until now a custom agent meant hand-authoring a flow: knowing the section types, the branch shape and the analytics config before you could send anything.
It does not create an agent. Nothing reaches your agent list, nothing is callable, and nothing is billable until you separately post the result to POST /agents. The 201 created a composer session. That is deliberate: it lets you put “here is the agent I’d build for you” in front of a person and let them decide.
Every response carries the agent as a body POST /agents accepts verbatim — no ids, no schemaVersion, the fallthrough route already named by defaultBranch — alongside a plain-language description of what the agent does, in at most ten sentences. That text is written for a human to read next to a “create this agent” button.
-
Refine it by saying what to change:
POST /agents/composer/{sessionId}/messages applies one instruction — “also ask about relocation”, “make it Slovak”, “drop the salary question” — and returns the updated preview. The session holds the agent, so a follow-up carries only your message; there is nothing to keep in sync and no stale version to send back by accident. GET /agents/composer/{sessionId} returns the current preview and the conversation so far.
A change that would break the conversation is rejected with a 422 and stores nothing — no half-applied edit, no orphaned message. Which is what makes the preview trustworthy: it has passed the create route’s validation at every step, not just at the start.
-
Create straight from a session:
POST /agents now accepts composerSessionId. Send it alone to build exactly what the preview showed, or alongside your own fields to override parts of it. While it is present, name, type, language and duration are optional. Either way the design conversation is attached to the new agent, so opening it in InstaView shows how it was built — and someone can carry on editing it there by hand.
-
The agent names itself: a composed agent arrives with a name derived from what it does, so
name is optional when you start a session. Send one and it wins, on that turn and every later one — the composer will not quietly relabel an agent you named. The same applies inside InstaView, where the copilot now fills the name field it used to leave blank.
Notes
- Designing an agent is a language-model call and takes a few seconds, so these two routes are allowed longer than the rest of the API — up to two minutes to design, one to refine. Allow for that on your client rather than timing out at a couple of seconds.
- Composer sessions live 30 days. A preview nobody accepted is then deleted along with its conversation;
expiresAt on every response says when. Creating the agent ends the clock.
- Both write routes carry their own rate limit on top of your key’s usual allowance — 6/minute, 60/hour to start a session and 10/minute, 120/hour to refine one — because each call runs a language model. Exceeding it is a
429.
- Both use the
agents write scope you already have; GET uses the read scope. There is no new scope to grant.
August 14, 2026
New Features
-
human_handoff: end a call by transferring it to a person: A custom agent’s flow accepts a fourth section type. human_handoff hands the call to a phone number — the agent says one line and the two people carry on without it. It works on POST /agents, PATCH /agents/{id} and the inline agent of POST /interviews, alongside the sequential, looping and conditional sections you already send.
phoneNumber is required and must be E.164. message is optional: omit it and the agent says a neutral line in the call’s language, which promises nothing about who picks up, because the block itself does not know. Send one when you do. As with every other text in a flow, {{contact.*}} placeholders are resolved per call.
The transfer is final, and it is the end of the conversation. We hand the call to the carrier and leave it — a cold transfer — so the agent does not come back: nothing after a handoff runs, the flow’s lastMessage included. The interview settles as COMPLETED and emits the terminal events it always did; the transcript and analysis cover your agent’s part of the call, since what the two people then say to each other is not ours to record.
Rather than store a step that could never run, a handoff is required to be the last block in its own list. A handoff ending one branch of a conditional is fine — the other branches still reach whatever follows it — which is the intended shape for this block: route on intent, then transfer to the number that fits.
There is no fallback number, on purpose. Once the carrier holds the call we are no longer on it, so nothing can notice the destination was busy and dial a second one. Express “try sales, otherwise support” as a conditional the agent routes on while it still has the call.
A flow that can reach two numbers needs a label on every handoff, and they must differ. The agent is offered every reachable destination at once and chooses between them by label — the number is never in its prompt — so two described alike would be a coin flip that lands a caller on the wrong team. One destination needs no label at all, and two blocks pointing at the same number are a single destination (deduplicated by number), free to share a label or omit it.
PHONE agents only. An ONLINE agent has no call to transfer, so a flow carrying a handoff is rejected with a 422 rather than storing a step that could never fire.
-
Seven new flow validation codes:
HUMAN_HANDOFF_NOT_PHONE (a handoff on an ONLINE agent), HUMAN_HANDOFF_NO_NUMBER, HUMAN_HANDOFF_INVALID_NUMBER (not E.164), HUMAN_HANDOFF_MESSAGE_EMPTY, HUMAN_HANDOFF_NOT_LAST (a block after a handoff in the same list), HUMAN_HANDOFF_LABEL_REQUIRED and HUMAN_HANDOFF_LABEL_DUPLICATE. They arrive in the same errors array as every other flow fault, each with the path of the offending block, and are all raised when the agent is created — a number the dialler could not use is a design error, not a surprise mid-call.
HUMAN_HANDOFF_MESSAGE_EMPTY is worth a note: message is optional, but present-and-blank is rejected instead of being read as absent. Clearing the field otherwise hands back the default line without ever saying so.
Behavior Changes
- The “not available yet” rejection now lists
human_handoff among the types you may use: sending an unbuilt section type is still a 422, and its message still names the alternatives — that list is generated from what is actually built, so it has grown by one. delay is now the only type the schema reserves without implementing. If you assert on that message in tests, this is the one string that changed.
Documentation
- The handoff block is documented with the other section types: Create Agent writes out the shape, the PHONE-only rule and the consequences of a cold transfer, and
openapi.json carries a FlowHumanHandoffBlock schema in the sections union — so a regenerated client has the type. The Agents guide lists it in the summary of what a section can be.
August 9, 2026
New Features
-
Agent knowledge base: give an agent documents to consult mid-call: An agent can now be handed documents — a job spec, a benefits summary, a pricing sheet, an FAQ — and it looks them up on its own while the conversation is running. This was already available in the InstaView dashboard; it is now on the API, for template and custom agents alike. There is nothing to reference in your flow and no publish step: a completed document is attached to the agent’s next call.
The file never passes through this API. You ask for a slot, upload the bytes straight to storage, then confirm:
The alternative would have been sending the file inline as base64, which inflates a 20MB document to roughly 27MB on the wire and caps out well below that in practice. Three small JSON requests carry a full-size file instead.
GET /agents/{id}/knowledge lists what is attached, PATCH /agents/{id}/knowledge/{documentId} takes { "isActive": false } to park a document without losing it, and DELETE /agents/{id}/knowledge/{documentId} erases it — the record, the stored file and the copy held by the voice provider. All of these use the scopes you already hold for agents: read:agents, write:agents and delete:agents respectively.
Accepted: PDF, DOC, DOCX, TXT, MD, CSV, TSV, JSON, XML, YAML — stored in the format you upload, with no conversion step. Limits are 20 MB per file and 10 documents per agent; upload URLs are valid for 15 minutes.
A document is PENDING until you complete it, and a PENDING document is never used on a call. It does hold one of the agent’s ten slots in the meantime, so a 400 about the document limit can mean uploads you started and never finished — GET the knowledge base to see them. Unfinished uploads are reclaimed automatically after 24 hours, or immediately if you DELETE them.
Completion is where the file is actually checked, not the request that issues the URL. The sizeBytes you declare up front is a fast pre-check so an unusable upload fails before you transfer 20MB; what is enforced at completion is the object storage really holds — it must exist, be within the size limit, and carry the content type the URL was issued for. A file that fails is deleted and its slot released. Completion is idempotent: completing an already-completed document returns it unchanged, so a call that times out on your side is safe to retry.
-
Two endpoints now carry a rate limit below your plan’s: Starting an upload reserves a slot and, once completed, a stored file, so
POST /agents/{id}/knowledge is limited to 6 requests per minute and 60 per hour per API key. Completing one is limited more generously — 20 per minute, 200 per hour — because retrying a failed completion should be cheap.
These are an additional ceiling, not a separate allowance: your plan’s limits are charged first, and being inside them is not enough for these two routes. A rejection is the usual 429 with a Retry-After header. Every other endpoint is unaffected. See Rate Limiting.
Documentation
August 5, 2026
New Features
-
overrides: have an agent introduce itself as one of your customers: An agent has always spoken as the company its API key belongs to, which is wrong for anyone calling on behalf of their own customers — the candidate hears the integrator’s name instead of the employer’s. Agents now take an optional overrides object on POST /agents, PATCH /agents/{id} and the inline agent of POST /interviews:
Both fields are optional and each falls back to your company profile, so every existing agent is unaffected. It works on template and custom agents alike, and applies from the next call — there is nothing to recompile.
Send both fields together. They fall back independently, so companyName on its own leaves your own company’s description in place and the agent introduces itself as Acme while describing you. PATCH replaces the object whole rather than merging it — the same contract as guardrails and contextConfig — so send every field you want kept. "overrides": null clears them.
Only what the agent says changes. Ownership, billing, analytics and phone-number routing keep your real company; the interview invitation email now names the same company the call will, so the two agree. You are responsible for having the right to speak in the name you send.
August 4, 2026
New Features
-
Custom agents: design the whole conversation instead of picking a template: An agent used to mean choosing a
focus — SCREENING, OUTREACH, GENERIC, LANGUAGE_TEST — and supplying questions. Those are hiring templates, which made the API awkward for anything that is not hiring. You can now send a flow instead and design the conversation yourself: a firstMessage, a list of sections, a lastMessage. Sections are sequential (a topic, written as a free-text prompt), looping (questions, each with the answer it is scored against) or conditional (branches taken on the contact’s intent).
An agent is custom the moment it carries a flow — do not send focus as well, it is derived and sending both is rejected. Two optional objects come with it: guardrails (things the agent must always or never do) and contextConfig (role, communicationStyle, callToAction). Both are accepted only alongside a flow.
Block ids and schemaVersion are assigned by InstaView and must not be sent — so a flow read back from the API has to have them stripped before it is sent again. An invalid flow is rejected with a 422 carrying an errors array listing every problem found, each with the path of the offending block, so fixing a flow does not take one request per fault. The same fields are accepted on the inline agent of POST /interviews, since that mints a real agent and cannot be a side door onto states POST /agents forbids.
Any text in a flow may contain {{contact.…}} placeholders, resolved per call rather than when the agent is created, so one agent serves everyone you call. See the custom agents guide.
A custom agent has no job, and will not take one. The hiring focuses associate an interview with a job; a flow-based agent is defined entirely by its flow, so a job would never be read. POST /interviews therefore rejects job, jobId and candidate.jobId with a 400 for a custom agent rather than storing an association nothing consumes. The corollary is the useful part: you never have to pick a job to interview someone. A contact assigned to five jobs, or to none, is called the same way — candidateId and agentId are all a custom interview needs.
-
analyticsConfig: get structured data back from a call, not just a transcript: A custom agent can now declare what should come back after every call, and the results arrive as data your own systems can act on.
Every item has a key, and it is yours. It is the field name the results come back under, so your code switches on it. Omit it and InstaView derives a slug of the label (Budget confirmed → budget_confirmed) — convenient, but renaming that label later silently moves the results to a new key and breaks your consumer with a 200. Set it explicitly if you read results by name. A duplicate key you sent is rejected with 400 rather than quietly renamed.
Scoring a flow question is set inline on the question, not in analyticsConfig — add importance and weight to the question itself, because questions are addressed by ids InstaView mints and you cannot know them while writing the flow. A question carrying an idealAnswer but neither importance nor weight is asked but not scored, so nothing enters your score that you did not ask for.
-
Conversation results: the new
analytics object: The configured results appear as analytics on GET /interviews/{id}, on every row of GET /interviews, and on the analysis.completed webhook.
It is a sibling of analysis, not a variant of it — analysis is the recruiting pipeline’s output and is typically absent on a custom call, which also has no analysisPdfBase64, since the PDF report is a recruiting artefact. Two details worth building against:
value: null on a field means the call did not surface it, which is distinct from a false or empty value that was surfaced. A contact who declines to answer gives you null; one who says “no” gives you false. confidence and evidence are omitted when the value is null.
weightPercent is each scored item’s share of the match score, and the shares sum to exactly 100. The weights you configure are relative, not percentages, so a raw 50 would tell a consumer nothing on its own.
This is additive and custom-agent-only. analytics is a new optional field on an existing event, not a new event type — there is no analytics.completed, and an agent with a hiring focus emits exactly what it always did, with no analytics key at all. An existing HR integration observes no change.
Behavior Changes
-
Interview and analysis events now fire for interviews you did not create through this API:
interview.started, interview.completed, interview.failed, interview.rescheduled, interview.cancelled, analysis.completed and analysis.failed were silently limited to interviews created through POST /interviews. An interview a recruiter launched from the InstaView dashboard — including every contact of a bulk calling run — emitted nothing at all, even with an active webhook on exactly those events for that company. Delivery is decided by the company the webhook belongs to, as it always was for choosing endpoints, so these events now cover every interview in that company.
If you assumed every interviewId on these events was one you created, that is no longer true. The volume on an existing subscription can rise accordingly. Filter on the interview ids you know about if you only want your own. Note that analysis.completed carries the full analysis and a PDF, so this is the subscription most worth reviewing.
-
A webhook on an ATS parent company now receives its child companies’ events regardless of how the resource was created: The parent was reached through the API key that created the resource, not through the company hierarchy. So a parent-registered endpoint already received a child’s events when the resource was created with that parent’s ATS key — the common case, and unchanged — but received nothing when the same child’s interview was created with the child’s own API key, or from the InstaView dashboard. The parent is now resolved from the company hierarchy, so all three behave the same.
If you register on both the parent and a child, the child’s events reach both endpoints. For resources created with the parent’s ATS key that was already true; it now also applies to resources created with the child’s own key. A child’s webhook is unaffected: it still receives only that child’s events, never a sibling’s and never the parent’s.
The two copies are separate deliveries with separate
deliveryIds, so deliveryId alone will not collapse them — it still does the job it is documented for, which is recognising a redelivery of the same delivery. The key that does collapse them differs per event, and is tabulated under Handling Duplicates. Note in particular that data.interviewId is only a safe key for interview.cancelled; the per-attempt events fire repeatedly for one interview, and keying on the interview alone would drop events you want.
Bug Fixes
-
A cancelled calling run now closes its contacts out: Cancelling a bulk calling run moves every contact it had not yet dialled to
CANCELLED, but emitted no interview.cancelled for any of them, so an integrator was left with rows that never closed. Each interview the cancellation closes out now emits interview.cancelled with reason: "USER_REQUEST". Contacts the cancellation deliberately leaves alone — a call already in progress — still close through their own call-ended path and are not reported twice.
These particular events are queued rather than sent the instant the run is cancelled, so that cancelling a run with thousands of contacts stays fast for the operator. They are delivered by the same worker that handles retries, normally within a minute or so, and larger runs drain over several minutes. Signing, retries and ordering guarantees are unchanged; only the timing of this one event differs, and every other event still goes out immediately.
Documentation
- Event scope is stated in the webhooks guide: the Event Types section now says that interview and analysis events cover every interview in the company, not only those created through the API, and that a webhook on an ATS parent company also receives its children’s events.
August 3, 2026
Breaking Changes
This lands for existing integrations on 4 September 2026, 00:00 Europe/Prague. Until
then production keeps serving the old shape, so nothing breaks today — you have until that
date to migrate. The new shape is live on staging now; test against it there.While you are still receiving the old shape, every response carries two headers:
Deprecation: true, which marks the response as coming from a superseded contract, and
Sunset, holding the exact deadline. Watch for either in your own logs — when they stop
appearing, you are on the new shape.
-
Responses no longer carry the
{ statusCode, message, data, traceId } wrapper: The body of a successful response is now the resource itself. Reading a field that used to live at response.data.x now means reading response.x.
List endpoints lose a level of nesting in particular. The page object’s own array is called data, so under the wrapper it sat at data.data — the reason for this change. The counts move up beside it:
Affected: every route under /agents, /candidates, /companies, /interviews, /jobs, /phone-numbers, /billing and /webhooks. /sourcing and /voice already answered without the wrapper and are unchanged.
Nothing else about these responses changed — the same fields carry the same values, one level higher. The HTTP status code already told you the status the statusCode field repeated, and message was the constant string "Success".
If you generated a client from our OpenAPI specification, your client already expected this shape: the published spec has always declared the payload type rather than the wrapper, and this change makes the API match the document rather than the other way round.
-
Delete routes answer with the deleted id instead of a bare boolean:
DELETE on /agents/{id}, /candidates/{id}, /companies/{id}, /interviews/{id} and /jobs/{id} returned "data": true inside the wrapper. Without the wrapper that body would have been the literal true, which names neither what was deleted nor which resource it was, so these routes now return { "id": "…", "deleted": true }. A failed delete still answers with an error status, not deleted: false.
-
The trace id moved to the
x-trace-id response header: It was the wrapper’s traceId field. Every response now carries it as a header instead — including 204s and the audio stream from /voice, which have no JSON body to put it in. Error bodies keep their traceId field as well, unchanged. Quote this value when contacting support.
Behavior Changes
-
/jobs/{id} no longer distinguishes a foreign id from a missing one: GET, PATCH and DELETE on a job that belongs to another company previously answered 403 Forbidden with "Access denied to this job", while an id that existed nowhere answered 404 Not Found. The pair was usable as an oracle — one request per id told you whether that id existed in someone else’s company — and job ids are the ones most likely to be guessed, because they travel through ATS integrations. All three routes now answer the same 404 Not Found with "Job not found" for both cases, byte for byte. This matches /agents/{id}, /interviews/{id} and candidate deletion, which have always answered this way.
If you branch on 403 to detect a cross-company job, that branch is now dead. Treat 404 as “this job is not available to this key” and do not read it as confirmation that the id exists elsewhere. 403 still means what it always meant on these routes: your API key is missing the required scope.
-
DELETE /jobs/{id} answers 404 for an id that never existed, instead of 200: The route previously returned 200 with "data": false when asked to delete an id it could not find, so a typo and a real deletion were told apart only by the body. It now answers 404 Not Found, the same as DELETE /agents/{id}. A 200 from this route means a job was really deleted; for the body it returns, see the response-shape change above.
If you treat this route as an idempotent delete — repeating it and accepting the second 200 — the repeat now returns 404. Deletion itself is unchanged: the first call still permanently removes the job and its candidate assignments.
Documentation
- The by-id 404 is documented as deliberate:
openapi.json and the job and agent reference pages now state that an id that exists nowhere and an id in another company answer identically on purpose, so the response cannot be used to tell them apart. The DELETE operations on both resources were also described as soft deletes in openapi.json; both have always been permanent, and the descriptions have been corrected.
August 2, 2026
Breaking Changes
-
New interview status:
UNREACHABLE: Interviews that exhaust their call attempts without ever reaching the candidate now settle on UNREACHABLE rather than FAILED or CANCELLED. The terminal vocabulary was COMPLETED / FAILED / CANCELLED, none of which described “we called the permitted number of times and nobody picked up” — so that outcome was split across the other two depending on internal timing.
- Where it appears: the
status field on GET /interviews, GET /interviews/{id}, and PATCH /interviews/{id}, plus the status query filter on GET /interviews. Generated clients built from a previous OpenAPI snapshot will reject responses carrying the new value until regenerated.
- What lands there: the last attempt ended in no answer, busy, or voicemail and the retry budget is spent. Technical failures — the call could not be placed or held — remain
FAILED. Deliberate cancellations remain CANCELLED.
- If you filter or report on
FAILED or CANCELLED interviews, this cohort has moved. Historical interviews were backfilled, so the change is visible in past data too, not only in newly created interviews.
-
This supersedes the July 20, 2026 behavior change. That entry announced that interviews exhausting their call attempts would be
CANCELLED rather than FAILED. That reclassification was only ever applied on one of the two internal paths that end a retry-exhausted interview; the other left them FAILED, and in the most common cases left them stuck in SCHEDULED indefinitely (see below). Both paths now agree on UNREACHABLE.
Bug Fixes
-
Interviews no longer stall in
SCHEDULED forever: An interview whose call attempts had all finished could remain SCHEDULED permanently, never emitting a terminal webhook and never settling into a terminal status. Two independent causes, both fixed:
- Retry exhaustion stamped the final call attempt with the specific reason it failed —
NO_ANSWER, BUSY, VOICEMAIL, TIMEOUT, FAILED, DISCONNECTED — and the interview was only closed for statuses considered final. Those reasons are individually retryable, so they were not on that list, and the ordinary outcomes never closed the interview.
- Removing a queued call attempt from the internal admin panel cancelled the attempt without closing its interview. Because such attempts were never dispatched, no provider callback ever arrived to close it either.
- Affected interviews have been backfilled to the status they should have reached, reaching back to interviews created in October 2025. If you polled these and gave up, or reconciled them as abandoned on your side, they now carry a terminal status and a
finishedDate.
-
Terminal webhooks now fire for exhausted interviews: Because those interviews never reached a terminal status, they never emitted
interview.cancelled either, contradicting the “exactly one terminal event per interview” guarantee documented in July. They now emit it.
-
Wrong-number, language-barrier and anti-robot calls now end the interview: These three outcomes were recorded on the call attempt but classified as neither final nor retryable, so no follow-up attempt was scheduled and the interview was never closed — it stalled on the very first such call. They are now terminal. A wrong number or an automated gatekeeper that ended the call before the candidate was ever on the line resolves to
UNREACHABLE; a language barrier resolves to FAILED.
Behavior Changes
interview.cancelled payload status vs. the interview resource: The webhook payload still carries "status": "CANCELLED" for every cancellation reason, including MAX_ATTEMPTS_EXCEEDED. The interview resource fetched afterwards will read UNREACHABLE for that reason. Branch on the payload’s reason field rather than data.status, and re-read the resource if you mirror interview status into your own system. The payload field is unchanged so existing consumers keep working.
Documentation
- Interview status values corrected and documented: The Interviews resource guide listed status values in lower case (
scheduled, in_progress) and omitted UNDEFINED. The API has always returned upper-case values. The section now lists every value with its correct casing, whether it is terminal, and what puts an interview there.