# Design an Agent
Source: https://docs.instaview.sk/api-reference/agents/compose-agent
POST /agents/composer
Starts designing a complete custom agent from a plain-language brief. Returns 202 immediately with a `sessionId`; poll `GET /agents/composer/{sessionId}` until `status` is `ready`. Designing an agent is a language-model call that takes tens of seconds, which is why it does not happen inside this request. **No agent is created** until you post the finished agent (or `composerSessionId`) to `POST /agents`.
Describe the agent you want in plain language and get back the agent InstaView would build for you — the whole conversation, ready to create.
**This does not create an agent.** Nothing appears in your agent list, nothing can be
called, and nothing is billable until you separately post the result to
[`POST /agents`](/api-reference/agents/create-agent). The `202` returned here created a
**composer session**, not an agent.
That is the point of this endpoint: you can put "here is the agent I'd build for you" in front of a person, let them read it, change their mind twice, and only then create anything.
## It answers before the agent exists
Designing an agent is a language-model call that takes tens of seconds, so this route does not hold your request open for it. It returns **202** straight away with a session id, and the design happens in the background.
```javascript theme={null}
POST /agents/composer
{
"type": "PHONE",
"message": "Screen senior backend devs. Ask about Go and Postgres, check notice period and salary expectation, then hand off to a recruiter if they're a strong fit."
}
```
```javascript theme={null}
{
"sessionId": "9f8c1d2e-4b7a-4c3d-9e1f-2a5b6c7d8e90",
"status": "processing",
"expiresAt": "2026-09-17T10:15:00.000Z"
}
```
Then poll [`GET /agents/composer/{sessionId}`](/api-reference/agents/get-composer-session) until it stops saying `processing`. It answers **202** while the work is running and **200** when it has finished, so you can branch on the status code without parsing the body:
```javascript theme={null}
GET /agents/composer/9f8c1d2e-4b7a-4c3d-9e1f-2a5b6c7d8e90 → 202
GET /agents/composer/9f8c1d2e-4b7a-4c3d-9e1f-2a5b6c7d8e90 → 202
GET /agents/composer/9f8c1d2e-4b7a-4c3d-9e1f-2a5b6c7d8e90 → 200, status "ready"
```
Every five seconds is a sensible interval. Most designs land in well under a minute.
**Or be told instead of asking.** Send `webhookId` with the brief and we will call your
webhook when a turn finishes — `composer.completed` carrying the same agent the poll would
have given you, `composer.failed` carrying the reason and no agent.
We only send the events that webhook subscribes to. Subscribing to one of the two is enough
to be accepted, and is a reasonable choice, but you then hear nothing about the other outcome:
subscribe to both, or poll for the one you did not take. Polling always works as well, and is
always authoritative.
Two fields are required: your `message`, and the `type` of agent to design.
**`type` is required and cannot change later.** The channel decides which conversation
steps are legal at all — a transfer to a person exists only on `PHONE` — so it has to be
known before the agent is designed rather than guessed from your brief.
Everything else is optional and, if you send it, **wins over what the composer chose** — on this turn and on every later one. That is what `name`, `language`, `duration`, `voiceId`, `backgroundSound`, `companyPhoneNumberId` and `metadata` are for: pin the things you already know, and let the composer decide the rest.
**Ask for a calling window in words.** "Only call weekday mornings, and try twice" is
understood, and the window comes back on the preview as `callConfig` — already in the shape
[`POST /agents`](/api-reference/agents/create-agent) accepts, so it survives whether you post
the preview back or just the `composerSessionId`. It is not a field of this route: to set one
exactly rather than describe it, send `callConfig` in your create call, where it wins over
whatever the composer chose. When neither of you says anything, a new agent calls Mon–Fri
09:00–18:00 in each contact's own timezone.
**You do not send a `flow`.** `flow`, `focus`, `guardrails`, `contextConfig` and
`analyticsConfig` are what the composer writes for you, and sending any of them is
rejected with a `400` naming which. If you already have a flow, you do not need the
composer — post it straight to [`POST /agents`](/api-reference/agents/create-agent).
## What the finished session contains
Once `status` is `ready`, the poll response carries the agent and the conversation:
```javascript theme={null}
{
"sessionId": "9f8c1d2e-4b7a-4c3d-9e1f-2a5b6c7d8e90",
"status": "ready",
"agent": {
"name": "Senior Backend Phone Screen",
"type": "PHONE",
"language": "EN",
"flow": { "firstMessage": { "…": "…" }, "sections": [], "lastMessage": { "…": "…" } },
"contextConfig": { "…": "…" },
"analyticsConfig": { "…": "…" }
},
"messages": [
{
"id": "…",
"role": "assistant",
"content": "Your agent opens by introducing itself and confirming it's speaking with the right person. It then works through Go and Postgres experience, asks about notice period and salary expectation, and transfers strong candidates to a recruiter. Anyone who isn't a fit gets a polite close.",
"changes": ["Named it Senior Backend Phone Screen", "Set the language to EN", "Added Skills deep-dive"],
"createdAt": "2026-08-18T10:15:00.000Z"
}
],
"expiresAt": "2026-09-17T10:15:00.000Z"
}
```
The newest assistant message is the accompanying description: a plain-language account of the agent, **in at most ten sentences** — what it opens with, what it asks about, what it decides, and how it ends. It is written for a person, not for a parser. Its `changes` is a short list of labels for what the composer did: on the first turn what it built, on later turns what changed.
## When the design fails
A brief the composer cannot turn into a valid agent does not fail your request — your request was already answered. The session ends up `failed` instead, and the poll tells you why:
```javascript theme={null}
{
"sessionId": "9f8c1d2e-4b7a-4c3d-9e1f-2a5b6c7d8e90",
"status": "failed",
"error": {
"code": "COMPOSER_INVALID_RESULT",
"message": "Could not generate a valid flow from that description. Try rephrasing or adding detail.",
"details": []
},
"expiresAt": "2026-09-17T10:15:00.000Z"
}
```
`COMPOSER_INVALID_RESULT` is worth rephrasing for. Any other code is worth retrying unchanged.
## What to do with the preview
`agent` is a body [`POST /agents`](/api-reference/agents/create-agent) accepts **verbatim** — no ids, no `schemaVersion`, and a conditional's fallthrough already named by `defaultBranch`. Nothing to strip, nothing to fix up.
Send another message to the session — "also ask about relocation", "make it Slovak".
Post the `agent` object, or just `{ "composerSessionId": "…" }`.
The simplest way to create it is to hand back the session id and let us build exactly what you were shown:
```javascript theme={null}
POST /agents
{ "composerSessionId": "9f8c1d2e-4b7a-4c3d-9e1f-2a5b6c7d8e90" }
```
Send fields alongside it to change your mind at the last moment — `{ "composerSessionId": "…", "name": "Backend Screen v2" }` creates the previewed agent under a different name.
**Wait for `ready` before creating.** A session whose design has not finished has no agent
to create, and posting its id is a `409` telling you to poll. A session whose *later*
refinement is still running is fine: it still holds the last agent you were shown, and that
is what gets created.
**The conversation follows the agent.** However you create it, the messages that designed
it are 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.
## Sessions expire
A session lives **30 days**. After that, a preview nobody accepted is deleted along with its conversation; `expiresAt` on every response tells you when. Creating the agent ends the clock — a session that became an agent is kept.
## Rate limits
Every call here designs an agent with a language model, so these routes carry their own limit on top of your key's usual allowance: **6 per minute and 60 per hour** for this endpoint, **10 per minute and 120 per hour** for [refining](/api-reference/agents/refine-agent). Exceeding it is a `429`.
## Errors
These are the ways the *request* can fail. A design that fails after it has been accepted is not an error here — it is a `failed` session, described above.
| Status | When |
| - | - |
| `400` | `message` or `type` missing, malformed, or over 8,000 characters — or you sent `flow`, `focus`, `guardrails`, `contextConfig` or `analyticsConfig`, which the composer writes (`property flow should not exist`) |
| `403` | The `webhookId` you sent belongs to another company |
| `404` | The composer is not enabled for your account, or the `webhookId` does not exist |
| `422` | The `webhookId` you sent is not subscribed to `composer.completed` or `composer.failed` |
| `429` | Composer rate limit reached |
# Create Agent
Source: https://docs.instaview.sk/api-reference/agents/create-agent
POST /agents
Creates a new agent scoped to the API key's company. Send a `flow` (with optional `guardrails` and `contextConfig`) to create a custom agent — its focus is then derived and must not be sent; otherwise pick a template `focus`.
Creates an agent — the AI that runs a call — scoped to the API key's company. An agent holds the voice, the conversation it will have, and what it should extract from it.
## Overview
An agent conducts the conversation with your contact. Give it a voice, a `focus` (or a custom `flow` of your own), the questions it should ask and how to judge the answers. One agent can run any number of conversations.
## Use Cases
* **Technical Screening**: Create agents for technical skill assessments
* **Behavioural screens**: Configure agents for soft skills evaluation
* **Culture Fit Assessment**: Set up agents to evaluate cultural alignment
* **Initial Screening**: Create quick screening agents for high-volume applications
## Basic Agent Creation
```javascript theme={null}
{
"name": "Technical Screening Agent",
"type": "ONLINE",
"focus": "SCREENING",
"language": "EN",
"duration": 30,
"questions": [
"Tell me about your React experience",
"Explain microservices architecture"
]
}
```
## Comprehensive Agent Configuration
> **Note:** The `duration` field is specified in minutes (maximum 180 minutes).
```javascript theme={null}
{
"name": "Senior Engineer Technical Interview",
"type": "ONLINE",
"focus": "SCREENING",
"language": "EN",
"duration": 30,
"questions": [
"Describe your experience with system design",
"Explain how you would scale a web application",
"Tell me about a challenging technical problem you solved"
],
"instructions": "Focus on technical depth and accuracy, problem-solving approach, and communication clarity"
}
```
## Phone Agents
When creating an agent with `type: "PHONE"`, you must provide a valid `companyPhoneNumberId`. This ID corresponds to a phone number assigned to your company that the agent will use for calls.
```javascript theme={null}
{
"name": "Phone Screening Agent",
"type": "PHONE",
"focus": "SCREENING",
"language": "EN",
"duration": 15,
"companyPhoneNumberId": "123e4567-e89b-12d3-a456-426614174000",
"questions": [
"Are you available for a 30-minute call next week?",
"What is your expected salary range?"
]
}
```
## Background Sound
Use `backgroundSound` to control the ambient noise played to the contact during calls.
| Value | Description |
| - | - |
| `OFFICE` | Subtle office ambient noise (default) |
| `OFF` | Complete silence |
```javascript theme={null}
{
"name": "Phone Screening Agent",
"type": "PHONE",
"focus": "SCREENING",
"language": "EN",
"duration": 15,
"companyPhoneNumberId": "123e4567-e89b-12d3-a456-426614174000",
"backgroundSound": "OFFICE",
"questions": [
"Are you available for a 30-minute call next week?"
]
}
```
## Voice Options
* **ALEX** (Male)
* **PETER** (Male)
* **MIRIAM** (Female)
* **SUE** (Female)
* **VIERA** (Female)
* **CASANDRA** (Female)
* **SILVIA** (Female)
* **MICHAEL** (Male)
* **LUKE** (Male)
* **EMMA** (Female)
* **SARAH** (Female)
* **EVA** (Female)
## Agent Focus
`focus` sets what kind of call the agent runs. Four are supported, all of them hiring-shaped; for anything else send a `flow` instead and the agent becomes a custom one with no focus at all:
* **GENERIC**: job-optional. The conversation is not about a particular job, so no job assignment is needed — talent-pool building, general outreach, a first call before anyone is matched to a role.
* **SCREENING**: a hiring screen, evaluating the contact against a job's requirements.
* **OUTREACH**: a first call to gauge interest and re-engage people already in your database.
* **LANGUAGE\_TEST**: language proficiency, scored against CEFR.
### Example with Focus
```javascript theme={null}
{
"name": "Talent Pool Agent",
"type": "ONLINE",
"focus": "GENERIC",
"language": "EN",
"duration": 30,
"questions": [
"Tell me about your career goals",
"What type of opportunities are you looking for?"
]
}
```
**GENERIC is job-optional**: a conversation using `focus: "GENERIC"` does not need a job, and
the contact may hold any number of job assignments — they are simply not used. You may
still attach a job explicitly if you want the conversation recorded against one. Custom agents
(created with a `flow`) are job**less** rather than job-optional and reject an attached job
outright. See the [Create Conversation](/api-reference/conversations/create-conversation)
documentation for more details.
## Speaking for Another Company
By default an agent introduces itself as the company your API key belongs to. If you call on behalf of your own customers, `overrides` lets each agent speak as one of them instead:
```javascript theme={null}
{
"name": "Acme Warehouse Screening",
"type": "PHONE",
"focus": "SCREENING",
"language": "EN",
"duration": 15,
"companyPhoneNumberId": "123e4567-e89b-12d3-a456-426614174000",
"overrides": {
"companyName": "Acme Manufacturing",
"companyDescription": "Acme makes industrial fasteners and employs 400 people across Slovakia."
}
}
```
Both fields are optional and each falls back to your own company profile. It works on any agent — template or custom — and is picked up on the next call, with nothing to recompile.
**Send both fields together.** They fall back independently, so `companyName` on its own
leaves your own company's description in place — the agent introduces itself as Acme and
then describes you. If your company profile has a description, override both.
**Only what the agent says changes.** The agent still belongs to your company, and
billing, analytics and phone-number routing all keep it — as does the address an
invitation to a web conversation is sent from. The company *named inside* that invitation follows the
override, so the email and the call that follows it agree. You are responsible for having
the right to speak in the name you send.
## Creating from a Composer Session
If you would rather describe the agent than write a `flow`, [design it first](/api-reference/agents/compose-agent) and create it from the session you get back:
```javascript theme={null}
{ "composerSessionId": "9f8c1d2e-4b7a-4c3d-9e1f-2a5b6c7d8e90" }
```
That builds exactly the agent the preview showed. Send fields alongside it to override parts of it — `{ "composerSessionId": "…", "name": "Backend Screen v2" }` — or post the preview's `agent` object yourself, with the session id attached, if you edited it locally first. Anything you send wins over the preview.
While `composerSessionId` is present, `name`, `type`, `language` and `duration` are optional: the session already has them.
**The design conversation comes with it.** The messages that produced the agent are
attached to it, so opening that agent in InstaView shows how it was built and lets someone
keep editing it by hand.
## Custom Agents
Instead of picking a `focus` and a list of `questions`, you can design the whole conversation yourself by sending a `flow`. An agent created with a flow is a **custom agent**: it runs the conversation you designed rather than one of the built-in templates, which is what makes it usable outside hiring — sales outreach, support callbacks, operations checks.
A flow is a `firstMessage`, one or more `sections`, and a `lastMessage`. `sections` is the body of the conversation: every step you can add on the builder canvas is one entry in that array, and its `type` decides the rest of its shape.
| Step in the visual builder | `type` | Purpose |
| - | - | - |
| Topic / agenda | `sequential` | A topic to cover, written as a free-text `prompt` |
| Question set | `looping` | A list of `questions`, each with an optional `idealAnswer` to score it against |
| Conditional route | `conditional` | `branches`, each with a `label`, a `condition` and its own nested `blocks` |
Each shape is written out in [Section types](#section-types) below.
**Do not send `focus` with a `flow`.** An agent that carries a flow is custom by
definition, so its focus is derived — sending both is rejected.
**Ids and `schemaVersion` are assigned by InstaView.** Do not send `id`,
`defaultBranchId` or `schemaVersion` anywhere in the flow; a request that does is
rejected. To give a conditional a fallthrough route, set `defaultBranch` to the
**label** of the branch it should fall through to. A flow read back from the API
contains the assigned ids and version, so strip all three before sending it again —
everything you authored, `defaultBranch` included, comes back as you wrote it, so a
stripped flow re-creates the same conversation.
Three optional objects configure a custom agent, and are accepted only together with a `flow`:
* `guardrails` — `{ "rules": [...] }`, things the agent must always or never do (max 50).
* `contextConfig` — who the agent is (`role`, `communicationStyle`), what the call is for (`callToAction`), and whether the agent is told what you know about the person it is calling (`useContactContext`).
* `callConfig` — how persistently the agent calls, and when. See [Retries and calling hours](#retries-and-calling-hours).
`communicationStyle` is one of a fixed set — `professional`, `friendly`, `warm and empathetic`, `assertive`, `concise and direct`, `enthusiastic`, `casual` — the same list the visual builder offers, so an agent created over the API stays editable there.
**You do not describe your company here.** The description on your company profile is
given to every custom agent automatically, and resolved at the moment of each call — so
editing the profile reaches the next call without touching a single agent. It also means
one agent can call on behalf of several companies and introduce each correctly. To have an
agent speak as a *different* company, use [`overrides`](#speaking-for-another-company).
```javascript theme={null}
{
"name": "Outbound Discovery Call",
"type": "PHONE",
"language": "EN",
"duration": 10,
"companyPhoneNumberId": "123e4567-e89b-12d3-a456-426614174000",
"flow": {
"firstMessage": { "type": "first_message", "text": "Hi {{contact.first_name}}, do you have two minutes?" },
"sections": [
{ "type": "sequential", "label": "Intro", "prompt": "Introduce yourself and why you are calling." },
{
"type": "looping",
"questions": [
{ "question": "What are you using today?", "idealAnswer": "Names a competing tool" },
{ "question": "What is your timeline?", "idealAnswer": "Within this quarter" }
]
},
{
"type": "conditional",
"defaultBranch": "Not now",
"branches": [
{
"label": "Interested",
"condition": { "type": "intent", "description": "wants to see a demo" },
"blocks": [{ "type": "sequential", "prompt": "Offer two slots this week and confirm one." }]
},
{
"label": "Not now",
"condition": { "type": "intent", "description": "declines or asks to be contacted later" },
"blocks": [{ "type": "sequential", "prompt": "Ask when to follow up and thank them." }]
}
]
}
],
"lastMessage": { "type": "last_message", "text": "Thanks for your time — have a great day!" }
},
"guardrails": { "rules": ["Never quote a price", "Never promise a delivery date"] },
"contextConfig": {
"role": "an account executive for Acme",
"communicationStyle": "concise and direct",
"callToAction": "book a 30-minute demo",
"useContactContext": true
}
}
```
### Telling the agent about the person it is calling
`contextConfig.useContactContext` gives the agent a short summary of what you hold about each
contact, built **per call** — so one agent serves a whole list and each call is told about the
person actually on the line.
```javascript theme={null}
"contextConfig": { "role": "an account executive for Acme", "useContactContext": true }
```
It comes in two halves, and the difference is worth knowing when you decide what to store. A
short platform-decided set of fields goes into the agent's **prompt**, so it is always known.
Everything else the contact has a value for is generated into a **document the agent can query
mid-call**, alongside any files on the contact. A prompt line is spent on every call; a document
entry costs nothing until the agent looks it up — but it is only used if the agent thinks to
look.
There is nothing per-field to curate. Define fields, store values, turn the switch on.
```javascript theme={null}
// 1. Define a field
POST /contact-fields
{ "key": "plan_name", "label": "Plan", "dataType": "string" }
// 2. Store a value for a contact
PATCH /contacts/{id}
{ "fields": { "plan_name": "Growth" } }
// 3. Turn it on for the agent
PATCH /agents/{id}
{ "contextConfig": { "useContactContext": true } }
```
The agent is told it **may refer to** the summary and **must not volunteer it unprompted**,
list what it holds, or explain how it knows. It is also told the summary is data rather than
instructions, so a value that reads like a command is ignored.
**Fields marked `isPii` reach neither half.** They are excluded from the prompt and from the
document, which is what keeps `email` and `phone` off every call without anyone deciding. To
let one through, set `allowPiiInContext` on that field and the value joins both. There are
cases where that is the point — an amount owed on a collections call — and cases where it
is a liability. Nothing turns it on for you.
A contact's document is generated the first time a context-enabled agent is **scheduled** to
call them, and refreshed before a later call when a value or the catalog has changed. An
immediate ad-hoc call for a contact nobody has called before runs prompt-only rather than
waiting on it, and the document is ready for the next one.
Off unless set. See
[List Contact Fields](/api-reference/contact-fields/list-contact-fields) for the catalog, and
[Update Contact](/api-reference/contacts/update-contact) for the values.
### Section types
Everything the visual builder can put in a conversation is expressed inside `sections` — the API is not a reduced version of the builder. A section is an object with a `type`, and `type` selects which other fields it carries. In the schema reference on the right this is a `oneOf` on the `sections` items, which renders collapsed, so the four shapes are written out here in full.
`label` is optional on every section and is only a name for the step; it is what the builder shows on the canvas. `id` is assigned by InstaView — never send one.
#### `sequential` — a topic to cover
A step written as a free-text instruction, for when you want the agent to cover something in its own words rather than ask a fixed question. Supports `{{contact.*}}` variables.
```json theme={null}
{
"type": "sequential",
"label": "Intro",
"prompt": "Introduce yourself, say you are calling about their trial, and ask if now is a good time."
}
```
#### `looping` — a set of questions
A list of questions the agent works through. One block can hold several questions; use separate blocks when you want them in different places in the conversation.
`question` is the only required field. `idealAnswer` is what a good answer looks like, and it is optional: a question without one is still asked, just never scored. That is how you write a question set with no right answers, such as an internal survey or a satisfaction check.
`importance` and `weight` are how a question joins the agent's match score: set either one to score it (`REQUIRED` gates the score, `PREFERRED` contributes by weight), and a question with neither is asked but not scored. They sit on the question itself rather than in `analyticsConfig` because a question is addressed by an id InstaView assigns, which you cannot know while writing the flow.
```json theme={null}
{
"type": "looping",
"label": "Qualify",
"questions": [
{ "question": "What are you using today?", "idealAnswer": "Names a competing tool" },
{
"question": "What is your timeline?",
"idealAnswer": "Within this quarter",
"importance": "REQUIRED",
"weight": 40
}
]
}
```
#### `conditional` — take a different route
Splits the conversation. The agent evaluates the branches in order and follows exactly one; `blocks` is the route it then works through.
`blocks` holds sections of any type, including another `conditional`, which is how you build a decision tree. `condition` is usually `{ "type": "intent", "description": "..." }` — a plain-language intent the agent reads off the conversation — and may instead be an `expression` for a deterministic test on an extracted field. `defaultBranch` is the fallthrough taken when no condition matches, and it addresses the branch by its **`label`**, because branch ids are assigned by InstaView.
**The condition is what the agent routes on, so it has to be one it can read.** Every entry in `branches` must be an object to begin with (`CONDITIONAL_BRANCH_NOT_AN_OBJECT`), and every branch needs a condition of one of those two shapes: a missing or non-object condition is a `422` (`CONDITIONAL_CONDITION_MISSING`), a `type` that is neither `intent` nor `expression` is `CONDITIONAL_CONDITION_TYPE_UNKNOWN`, and a condition of a known type missing what that type needs — an `intent` without a `description`, an `expression` without a `field`, with an operator outside the documented set, or with no `value` for an operator other than `EXISTS` — is `CONDITIONAL_CONDITION_INVALID`. The `path` names the branch, e.g. `sections[0].branches[1].condition`. The condition on the branch you nominate as `defaultBranch` is checked like any other: it is not read while that branch is the fallthrough, but moving `defaultBranch` elsewhere later would put it back in charge of a route.
```json theme={null}
{
"type": "conditional",
"label": "Interest check",
"defaultBranch": "Not now",
"branches": [
{
"label": "Interested",
"condition": { "type": "intent", "description": "wants to see a demo" },
"blocks": [
{ "type": "sequential", "prompt": "Offer two slots this week and confirm one." },
{
"type": "looping",
"questions": [
{ "question": "Who else should join the demo?", "idealAnswer": "Names a decision maker" }
]
}
]
},
{
"label": "Not now",
"condition": { "type": "intent", "description": "declines or asks to be contacted later" },
"blocks": [{ "type": "sequential", "prompt": "Ask when to follow up and thank them." }]
}
]
}
```
#### `human_handoff` — hand the call to a person
Ends the agent's part of the call and transfers it to a phone number. `phoneNumber` is required and must be **E.164** (`+`, country code, no spaces or dashes) and a **real number for that country**: the right length and leading digits for its numbering plan, so `+42190012345`, a Slovak mobile one digit short, is rejected even though it has the E.164 shape. Either problem is a `422` with `HUMAN_HANDOFF_INVALID_NUMBER` on the block. A number the dialler could not use is rejected at design time rather than at the moment of transfer.
`message` is the line the caller hears as the call is transferred to this number, and it is optional — omit it and InstaView supplies a neutral line in the call's language. That default says only that a transfer is happening: the block knows a number and nothing else, so it cannot claim who picks up. Send a `message` when you do know. It supports `{{contact.*}}` and `{{system.*}}` variables like any other text in a flow. A `message` that resolves to nothing on a call, because it held only variables and none had a value, falls back to the neutral line, so the transfer is never silent. Sending `message` as an empty string is rejected rather than treated as absent, so clearing the field cannot silently give you the default back.
**Write `message` so it is true on every transfer to this number.** A caller who asks for a person before the flow reaches this step can be transferred here too, and hear this line. Describe the transfer ("I'll put you through to our sales team now") rather than what came before it ("Your card is blocked now, I'll put you through"). Anything the agent should say only at this point of the flow belongs in the step before the handoff. The agent says that step in a turn of its own and transfers once the caller has responded, so a spoken step before a handoff adds one exchange to the call. The transfer itself carries no words but `message`.
**Blocks that share a `phoneNumber` share one line.** A number has a single line, taken from the first handoff block that names it, reading the flow top to bottom with each branch in order. It is heard on every transfer to that number, including one made at a later block. A later block's `message` is never heard. If the first block has no `message`, the neutral line is used even when a later block has one. To give a number a line of your own, put it on the first block that names the number.
```json theme={null}
{
"type": "human_handoff",
"label": "Sales",
"phoneNumber": "+421900123456",
"message": "I'll put you through to Martin on our sales team now, one moment."
}
```
A handoff block also changes what happens when somebody asks for a person **away from** that
step. Every agent carries a standing rule for "can I speak to a human?"; on a flow with no
handoff it says a colleague will be in touch and ends the call, and on a flow that has one it
transfers instead, wherever in the conversation the request comes. Where it can, the agent
chooses the destination by `label`, and the caller can then hear that number's line: the
`message` of the first block that names it, or the neutral line if that block has none. That
is why a `message` has to make sense away from its step.
**The transfer is final, and it is the end of the conversation.** InstaView hands the
call to the carrier and leaves it (a cold transfer), so the agent does not come back:
**nothing after a `human_handoff` ever runs**, including your `lastMessage`. The conversation
settles as completed with the call recorded as forwarded, and the transcript covers your
agent's part of the call only — what the two people then say to each other is not ours to
record.
Because such a step could never run, **a handoff must be the last block in its own list**,
and a sibling after it is rejected with `HUMAN_HANDOFF_NOT_LAST`. That is scoped to
immediate siblings: a handoff ending one branch of a `conditional` says nothing about the
sections after that conditional, which the other branches still reach.
**PHONE agents only.** An `ONLINE` agent has no call to transfer, so a flow carrying a
handoff is rejected with a `422` (`HUMAN_HANDOFF_NOT_PHONE`) rather than creating an
agent with a step that could never fire.
A handoff may sit inside a `conditional` branch, which is the useful shape: route on intent, then transfer to the number that fits. Two branches transferring to the **same** number are one destination, not two — handoffs are deduplicated by number.
**A flow that can reach two numbers needs a `label` on every handoff, and they must
differ.** The agent is offered all reachable destinations at once and chooses between them
by label — the number is deliberately never in its prompt — so two destinations described
alike would make the choice a coin flip that lands a caller on the wrong team. Missing
labels are `HUMAN_HANDOFF_LABEL_REQUIRED` and a collision is
`HUMAN_HANDOFF_LABEL_DUPLICATE`. A single-destination flow needs no label: there is nothing
to tell apart. Two blocks pointing at the *same* number are one destination, so they may
share a label, or go without one.
**There is no fallback number.** Once the carrier has the call InstaView is no longer on
it, so nothing can notice that the destination was busy and try a second one. "Try sales,
otherwise support" is a `conditional` the agent routes on **while it still has the call**,
not a property of the handoff.
#### Limits
| What | Limit |
| - | - |
| Sections in a flow | 1–100 |
| Questions in a `looping` block | 1–50 |
| Branches on a `conditional` | 2–5 |
| Sections in one branch | up to 50 |
| Nesting depth of conditionals | 3 |
Exceeding any of these is rejected with `422`.
**These four types are the whole set.** The schema reserves nothing beyond them, so any
other value in `type` is simply an unknown block: it comes back as `UNKNOWN_BLOCK_TYPE`
in the `errors` array, alongside every other fault in the document.
A type may still be added to the schema before it works, and while it is in that state
sending one is rejected with a `422` naming the block and the types you may use instead.
That list is generated from what is actually built, so it is the authoritative answer to
what a section may be at any given moment.
### Flow Validation Errors
The flow is validated when you create the agent, using the same rules as the visual builder — an invalid flow is rejected with `422`, rather than failing later when a call is placed.
The rejection carries an `errors` array with **every** problem found in the document, so fixing a flow does not take one request per fault. Each entry has a machine-readable `code` and the `path` of the offending node in the flow **you sent**, which is what to anchor an editor or an error list to.
```json theme={null}
{
"statusCode": 422,
"message": "Invalid conversation flow.",
"errors": [
{
"code": "UNKNOWN_BLOCK_TYPE",
"message": "Unknown block type: teleport.",
"path": "sections[2].branches[0].blocks[1]"
},
{
"code": "SEQUENTIAL_PROMPT_EMPTY",
"message": "Sequential block prompt must not be empty.",
"path": "sections[0]"
}
],
"traceId": "6a707aee000000000c1e285eefed9980"
}
```
**Match on the codes you handle, and fall back on `message` for the rest.** The set of
codes grows as the flow schema does, and a new one is added without a major version.
**`path` is the anchor, and the only one.** Block ids are assigned by InstaView when a
flow is stored, so a flow that was just rejected has none to report — errors address
the document you sent, by position.
Not every `422` on this route is a flow-validation failure — one of the size limits above, a block type that is not available yet, or a `companyPhoneNumberId` that is not yours, is also a `422`. Those carry a `message` and **no** `errors` array, so treat `errors` as optional:
```json theme={null}
{
"statusCode": 422,
"message": "Branch blocks at sections[2].branches[0] exceeds the maximum of 50 (received 51).",
"traceId": "6a707aee000000000c1e285eefed9980"
}
```
The same `422` shape is returned by [Update Agent](/api-reference/agents/update-agent) and by an inline `agent` on [Create Conversation](/api-reference/conversations/create-conversation).
### Analytics
`analyticsConfig` says what should come back **after** every call. Without it a custom agent places a call and returns a transcript; with it you get structured data your own systems can act on. Like `guardrails` and `contextConfig`, it is accepted only alongside a `flow`.
| Field | What it does |
| - | - |
| `extractionTargets` | The values to pull out of each call. A target with an `ideal` also counts toward the match score. |
| `scoringCriteria` | Holistic judgements over the whole call — "handled objections", "built rapport". |
| `outcomes` | Did the call achieve what it was for? Decided met / not met per call. |
| `outputTags` | Labels the AI may assign to a call. |
| `capture` | Which qualitative artefacts to produce (summary, Q\&A, evaluation, sentiment, recording, transcript). All default to `true`. |
```javascript theme={null}
{
// …name, type, language, duration, flow as above…
"analyticsConfig": {
"extractionTargets": [
{
"key": "budget_confirmed",
"label": "Budget confirmed",
"type": "bool",
"ideal": "A confirmed budget of at least 10k",
"importance": "REQUIRED",
"weight": 50
},
{ "label": "Current tool", "type": "string" }
],
"scoringCriteria": [
{
"label": "Handled objections",
"description": "Acknowledged the objection and answered it with a concrete example",
"weight": 20
}
],
"outcomes": [
{ "label": "Meeting booked", "description": "The contact agreed to a specific date and time" }
],
"outputTags": [{ "label": "Needs follow-up", "description": "The contact asked to be called back" }],
"capture": { "sentiment": false }
}
}
```
#### Keys are yours
Every item has a `key` — the name its results come back under. **Set it if your integration reads results by name.** A key you choose is stable; a derived one is not:
* Omit `key` and InstaView derives a slug of `label` (`Budget confirmed` → `budget_confirmed`). Convenient, but renaming the label later silently moves the results to a new key, and your consumer breaks with a `200`. Two labels that slug to the same thing get a numeric suffix — `Budget` and `Budget?` become `budget` and `budget_2`, in the order you listed them.
* Send `key` and it is used verbatim, whatever you do to the label afterwards. Letters, digits and underscores, up to 64 characters, unique within its list — a duplicate you sent is rejected with `400` rather than quietly renamed, and `__proto__`, `constructor` and `prototype` are refused because they cannot be used as field names in the results.
Editing the agent in the visual builder re-derives each key from its label, so a key you
chose over the API can be replaced by someone editing that agent in the UI.
#### Scoring a flow question
Questions join the match score **inline, on the question itself** — not through `analyticsConfig`:
```javascript theme={null}
{
"type": "looping",
"questions": [
{ "question": "What are you using today?", "idealAnswer": "Names a competing tool", "importance": "REQUIRED", "weight": 30 },
{ "question": "What is your timeline?", "idealAnswer": "Within this quarter" },
{ "question": "How has the rollout felt so far?" }
]
}
```
Only the first question is scored. The second carries an `idealAnswer` but no `importance` or `weight`, so it stays out of the score — nothing is scored that you did not ask for. The third has no `idealAnswer` at all, so it cannot be scored even if you set `importance` on it.
This is inline for the same reason ids are assigned by us: a question is addressed by an
id InstaView mints, which you cannot know while writing the flow. Reading the agent back
returns the same inline form, so a round-trip works.
Weights are **relative**, not a budget — `50 / 30 / 20` and `5 / 3 / 2` score identically. Results report each item's share as a percentage.
The results arrive as `analytics` on the conversation ([Get Conversation](/api-reference/conversations/get-conversation)) and on the `analysis.completed` [webhook](/guides/webhooks).
### Retries and calling hours
`callConfig` answers two questions: how many times a contact who does not answer is called, and when the agent is allowed to dial.
| Field | What it does |
| - | - |
| `retryPolicy.maxAttempts` | Attempts per contact, counting the first, `1`–`5`. Reschedules the contact asks for come out of this budget; a network failure that never reached them does not. |
| `callWindow.days` | Days the agent may call on, from `mon` … `sun`. |
| `callWindow.start` / `callWindow.end` | Local hours the agent may call between, `"HH:mm"` on a 24-hour clock. `end` is exclusive and must be later in the day than `start`. |
| `callWindow.timezoneAnchor` | Whose clock `start` and `end` are on. This is the field that decides what the hours actually mean. |
Omit `callConfig` and a new agent calls **Mon–Fri, 09:00–18:00, in each contact's own timezone**, three attempts each.
#### Whose 09:00?
`timezoneAnchor.mode` is either `contact` or `fixed`.
With `contact`, the contact's timezone is detected **from their phone number, down to the area code** — not from their country. On one list, a `+1 212` number is called at 09:00 in New York, a `+1 310` number at 09:00 in Los Angeles, and a `+1 602` number at 09:00 in Phoenix, which does not observe daylight saving at all. The same holds inside any country that spans zones: `+7 4212` is Vladivostok rather than Moscow, `+55 92` is Manaus rather than São Paulo, `+52 664` is Tijuana rather than Mexico City.
```javascript theme={null}
{
// …name, type, language, duration, flow as above…
"callConfig": {
"retryPolicy": { "maxAttempts": 3 },
"callWindow": {
"days": ["mon", "tue", "wed", "thu", "fri"],
"start": "09:00",
"end": "18:00",
"timezoneAnchor": { "mode": "contact" }
}
}
}
```
With `fixed`, every contact is called on one named IANA zone, wherever they are — the right choice when the hours belong to your own operation rather than to the person being called.
```javascript theme={null}
{
"callConfig": {
"callWindow": {
"days": ["mon", "tue", "wed", "thu", "fri"],
"start": "09:00",
"end": "17:00",
"timezoneAnchor": { "mode": "fixed", "timezone": "Europe/Bratislava" }
}
}
}
```
A timezone is validated against the runtime's own timezone database, so a plausible-looking but nonexistent zone is a `422` on this request rather than a schedule that quietly never fires.
**Some prefixes cover more than one timezone.** An Australian `+61 8` number could be in
Adelaide or Perth, up to two hours apart, and a phone number cannot tell you which. In that
case the agent waits until the hours you set are correct in **every** contact's zone rather
than picking one — so an uncertain contact is called a little later, never two hours too
early. `timezoneAnchor.fallbackTimezone` (default `Europe/Bratislava`) covers the rarer case
where a number resolves to no timezone at all.
**A narrow window over a wide prefix can leave no valid moment at all.** Requiring the hours to
be correct in every contact's zone means a short window over zones far enough apart never lines
up: `09:00`–`10:00` cannot be true in both Adelaide and Perth at once. When that happens the
contact is **called anyway** 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`, is
how you avoid it.
Retries land inside the same window, so a contact is never called back outside the hours you set.
**A time you name yourself is not moved.** The window bounds the times *we* choose: when a
conversation created with no `scheduleTime` goes out, and every retry after a call that did not
connect. Pass a `scheduleTime` on [`POST
/conversations`](/api-reference/conversations/create-conversation), or a time on either call-attempt
route, and the call is placed then even if the window is shut, because you have told us
something the window cannot know. If you would rather your window always won, keep sending
times inside it: an appointment your contact agreed to is usually the case where you want the
override, and a bulk import is usually the case where you want to omit `scheduleTime` and let
the window place the call.
#### Agents created before calling windows existed
An agent from before this was configurable reports a `callWindow.mode` of `business_hours` and no other window fields:
```javascript theme={null}
{
"callConfig": {
"retryPolicy": { "maxAttempts": 3 },
"callWindow": { "mode": "business_hours" }
}
}
```
That is a legacy preset meaning **Mon–Fri, 08:00–20:00, in the contact's own timezone** — the hours those agents were already being called within, so nothing about their dialling changed when calling windows shipped. You cannot select it; sending a `callWindow` is how such an agent is moved onto a real one.
Those hours are not a new restriction and not a widening. Before agents carried a window, the
hours were attached to the phone number instead, and every number was created with exactly
this schedule. The preset reproduces it so that migrating changes nothing you can observe. The
practical difference between it and a window you set yourself is that you cannot edit it, and
that its timezone anchor is fixed to the contact.
### Contact Variables
Any text in a flow — messages, prompts, questions — may contain `{{contact.}}` placeholders. They are **not** filled in when you create the agent: the agent is compiled once and the placeholders are resolved per call, so one agent serves every person you call.
Seven keys are always available. The first four come from the contact on the conversation; the rest come from your company profile and the agent itself:
| Key | Value |
| - | - |
| `{{contact.first_name}}` | The contact's first name |
| `{{contact.last_name}}` | The contact's last name |
| `{{contact.email}}` | The contact's email |
| `{{contact.phone}}` | The contact's phone number |
| `{{contact.company_name}}` | Your company's name |
| `{{contact.company_description}}` | The description on your company profile |
| `{{contact.agent_name}}` | The voice agent's own name |
**`{{contact.company_name}}` is *your* company, not theirs.** It resolves from the company
placing the call, so `"I see you work at {{contact.company_name}}"` is a fluent sentence that is
wrong on every single call, and nothing rejects it. The contact's own employer is an ordinary
catalogued field — `organization` in the default catalog — set per contact like any other.
You can add your own. Send them as `fields` when you create the contact, then reference them by key:
```javascript theme={null}
POST /contacts
{
"firstName": "Jana",
"lastName": "Novák",
"phoneNumber": "+421900123456",
"fields": { "order_number": "AB-99", "renewal_date": "12 September" }
}
```
```javascript theme={null}
// …then, in the flow:
{ "type": "sequential", "prompt": "Confirm order {{contact.order_number}} renews on {{contact.renewal_date}}." }
```
Keys are `a-z`, `0-9` and `_`, and each one has to be a field in your company's catalog —
read [`GET /contact-fields`](/api-reference/contact-fields/list-contact-fields) for the ones
you have, or pass `?createMissingFields=true` on the contact write to have a new key defined
for you. A key with no value resolves to an empty string rather than being spoken aloud, so a
missing variable degrades quietly — but write prompts that still read correctly without it.
The seven system keys above are reserved: sending one in `fields` is a `422` naming the key,
not a write that quietly does nothing. `first_name`, `last_name`, `email` and `phone` are set
through their own properties on the contact; the other three are resolved by the platform and
cannot hold a per-contact value at all.
#### `{{contact.profile}}`
An eighth reserved key exists and is deliberately not in the table above. `{{contact.profile}}` is
the whole **rendered summary** of what we know about the contact — the block
`contextConfig.useContactContext` puts in the agent's prompt, a paragraph rather than a value. The
agent is given it on its own when that switch is on, so there is nothing to gain by writing the
token, and putting it in a message or a prompt makes the agent read that paragraph out loud. Like
the other reserved keys it cannot hold a per-contact value: sending `profile` in `fields` is a
`422`.
#### System Variables
A second namespace, `{{system.}}`, is filled from the platform's clock rather than from the
contact. It works anywhere `{{contact.*}}` does and needs no setup:
| Key | Value | Example |
| - | - | - |
| `{{system.today}}` | Today's date, in the contact's own timezone | `2026-09-10` |
| `{{system.now}}` | The contact's local date and time | `2026-09-10 14:30` |
| `{{system.day_of_week}}` | The English weekday name, which the agent says in the call's language | `Thursday` |
```javascript theme={null}
{ "type": "sequential", "prompt": "Offer a slot: today is {{system.day_of_week}}, so suggest early next week." }
```
The timezone is derived from the contact's phone number, falling back to UTC when the number
resolves to none. Every agent is separately told the current date and time whether or not it uses
these, so reach for them only when a line has to **state** a date or a day.
**A key with no value resolves to nothing, in both namespaces.** The placeholder disappears from
the line — it is never read out as a token, and the agent is never told a value was missing. So
`"Am I through to {{contact.organization}}?"` is spoken as `"Am I through to?"` for a contact
with no organisation. The agent is instructed to reword around the hole, but the reliable fix is
to write the sentence so it survives losing the value: put the token where it can be dropped, or
ask instead of asserting.
## Company Scoping
Agents are automatically scoped to your API key's company. You can only access
and use agents that belong to your company. ATS keys can access agents from
companies they manage by specifying the `companyId` query parameter.
## Related Resources
Learn about agent configuration and best practices
See how a conversation uses an agent
List and manage your agents
# Delete Agent
Source: https://docs.instaview.sk/api-reference/agents/delete-agent
DELETE /agents/{id}
Permanently deletes an agent that belongs to the API key's company. This action cannot be undone, and the delete cascades to every conversation the agent ran.
Permanently deletes an agent. **This action cannot be undone.**
## Overview
This endpoint removes an agent and, with it, every conversation that agent ran.
## Use Cases
* **Remove Unused Agents**: Clean up agents that are no longer needed
* **Archive Old Configurations**: Permanently remove outdated agent configurations
* **Compliance**: Delete agents for data retention compliance
* **Organization**: Maintain a clean agent library
## Permanent Deletion Behavior
Deleting an agent is irreversible, and it **also deletes every conversation created with that agent**, along with their transcripts, recordings and analyses.
## Impact on Existing Conversations
**Existing conversations do not survive.** Deletion cascades from the agent to its
conversations, so a completed call's transcript and analysis go with the agent that ran it.
Export anything you need first, or leave the agent in place — an unused agent costs nothing.
## Company Isolation
You can only delete agents that belong to your API key's company. Attempting to delete an agent from another company will result in a `403 Forbidden` error.
## Error Scenarios
* **404 Not Found**: Agent doesn't exist or has already been deleted
* **403 Forbidden**: Agent belongs to a different company
## Related Resources
Learn about agent management
View all active agents
Create new agents
# Get Agent
Source: https://docs.instaview.sk/api-reference/agents/get-agent
GET /agents/{id}
Returns a single agent, ensuring it belongs to the API key's company.
Retrieves a single agent by ID, with its full configuration, questions and evaluation criteria.
## Overview
Returns everything held about one agent: its name, description, voice settings, type, questions and evaluation criteria — useful for checking a configuration before you point a conversation at it.
## Use Cases
* **Review Agent Configuration**: Inspect an agent before scheduling a call with it
* **Validate Agent Details**: Verify questions and evaluation criteria
* **Agent Auditing**: Review agent configurations for compliance
* **Configuration Reference**: Get agent details for documentation or reporting
## Response Data
The response includes all agent configuration:
```json theme={null}
{
"id": "agent-uuid",
"name": "Technical Screening Agent",
"type": "ONLINE",
"focus": "SCREENING",
"language": "EN",
"duration": 30,
"questions": [
"Tell me about your React experience",
"Explain microservices architecture"
],
"instructions": "Focus on technical depth and accuracy, problem-solving approach",
"createdAt": "2024-01-15T10:00:00Z",
"updatedAt": "2024-01-20T14:30:00Z"
}
```
The example above is a template agent. A **custom** agent additionally returns its `flow`,
`guardrails`, `contextConfig`, `analyticsConfig` and `callConfig` — the last being how
persistently and when it calls, described under
[Retries and calling hours](/api-reference/agents/create-agent#retries-and-calling-hours).
An agent created before calling windows were configurable reports a `callWindow.mode` of
`business_hours`, a legacy preset meaning Mon–Fri 08:00–20:00 in the contact's own timezone;
template agents carry no `callConfig` at all.
## When to Use
Use this endpoint when you need complete agent details. For listing multiple agents, use the [List Agents](/api-reference/agents/list-agents) endpoint instead.
## Company Isolation
You can only access agents that belong to your API key's company. Attempting to access an agent from another company will result in a `403 Forbidden` error.
## Error Scenarios
* **404 Not Found**: Agent doesn't exist or has been deleted
* **403 Forbidden**: Agent belongs to a different company
## Related Resources
Learn about agent configuration
Modify agent configuration
Browse all available agents
# Poll a Composer Session
Source: https://docs.instaview.sk/api-reference/agents/get-composer-session
GET /agents/composer/{sessionId}
The session's status, its current preview and its conversation, newest first. This is what you poll after starting or refining: 202 while a turn is running, 200 once it has finished.
Whether the session is still working, the agent it is currently holding, and the conversation that shaped it.
This is what you poll after [designing](/api-reference/agents/compose-agent) or [refining](/api-reference/agents/refine-agent) an agent — both of those answer `202` and finish in the background. It is also how you pick up a session your integration started earlier, after a page reload, in another process, or when a human comes back to a preview a day later.
## The status code is the answer
| Code | Meaning |
| - | - |
| `202` | A turn is running. Poll again; every five seconds is plenty |
| `200` | The turn has finished — `status` is `ready` or `failed` |
The body is the same either way, so a poll loop can branch on the code alone and only parse once it gets a `200`.
```javascript theme={null}
{
"sessionId": "9f8c1d2e-4b7a-4c3d-9e1f-2a5b6c7d8e90",
"status": "ready",
"agent": { "name": "Senior Backend Phone Screen", "type": "PHONE", "flow": { "…": "…" } },
"messages": [
{
"id": "…",
"role": "assistant",
"content": "Your agent now asks about relocation, and the whole call is in Slovak.",
"changes": ["Added a relocation question", "Set the language to SK"],
"createdAt": "2026-08-18T10:22:00.000Z"
},
{ "id": "…", "role": "user", "content": "Also ask about relocation, and make the whole call Slovak.", "createdAt": "2026-08-18T10:22:00.000Z" }
],
"expiresAt": "2026-09-17T10:15:00.000Z"
}
```
`agent` is the current preview — the same body [`POST /agents`](/api-reference/agents/create-agent) accepts verbatim, reflecting every refinement so far.
## The three states
| `status` | What it means | Is `agent` there? |
| - | - | - |
| `processing` | A turn is running | Yes, unless this is the first turn |
| `ready` | The last turn succeeded | Yes |
| `failed` | The last turn produced nothing; see `error` | Yes, unless the first turn is the one that failed |
The pattern behind that last column: **a session never loses an agent it once had.** Only the first turn can leave you with no preview, because there was none to fall back on. Everything after that either improves the agent or leaves it exactly as it was.
So a `processing` session that already has an `agent` is showing you the one your last successful turn produced, and it is still postable while the next change is being applied.
## While a turn is in flight
The conversation ends on your own message, with no reply under it yet. That is not a gap in the data — the question has been asked and not yet answered. When the turn lands, the reply appears; when it fails, the question stays standing and `error` says why, rather than an invented answer appearing beneath it.
## Paging the conversation
`messages` is newest first. When there is an older page, `nextCursor` is present; pass it back as `cursor` to continue.
```javascript theme={null}
GET /agents/composer/{sessionId}?cursor=MjAyNi0wOC0xOFQxMDoyMjowMC4wMDBafG1zZy0x
```
No `nextCursor` means you have reached the beginning of the conversation.
The cursor is opaque. Do not construct or parse one — pass back what you were given. An
unreadable cursor is treated as no cursor, so you get the newest page rather than an error.
## Scope and lifetime
Requires the `agents` read scope. An unknown session, an expired one, and one belonging to another company are all a `404`.
Sessions live 30 days; `expiresAt` says when this one goes. Once you create the agent, the conversation moves with it and is no longer read here — it is part of the agent.
# Complete Knowledge Upload
Source: https://docs.instaview.sk/api-reference/agents/knowledge/complete-upload
POST /agents/{id}/knowledge/{documentId}/complete
Confirms the file was uploaded to the presigned URL. The stored object is verified — it must exist, be within the size limit, and carry the content type the URL was issued for — and only then does the document become usable on calls. Safe to retry: completing an already-completed document returns it unchanged.
Confirms that the file was uploaded to the presigned URL, and makes the document usable on calls.
## Overview
This is the third step of the [upload flow](/api-reference/agents/knowledge/start-upload). Until it succeeds the document stays `PENDING` and no call will consult it.
## What Gets Verified
The stored object is checked before the document is accepted:
* it exists — a completion call for a file that was never uploaded is rejected
* it is within the 20 MiB (20,971,520 bytes) size limit
* it carries the content type the upload URL was issued for
A file that fails verification is deleted from storage, and the document slot is released. Fix the problem and start a new upload.
The size you declared when reserving the slot is a fast pre-check, not the
authoritative one. What is measured here is the object storage actually holds.
## Idempotency
Completing a document that is already `READY` returns it unchanged, with no error. A completion call that times out on your side is safe to retry.
## After Completion
The document is `READY` and `isActive: true`. It is attached to the agent's next call automatically — there is no publish step, and no need to update the agent itself.
To park a document without deleting it, [deactivate it](/api-reference/agents/knowledge/update-knowledge).
## Error Scenarios
* **400 Bad Request**: Nothing was uploaded to the URL, or the stored file failed verification
* **404 Not Found**: No such document on that agent
* **429 Too Many Requests**: Rate limit exceeded — wait for `Retry-After` seconds
## Related Resources
* [Start Knowledge Upload](/api-reference/agents/knowledge/start-upload)
* [List Knowledge Documents](/api-reference/agents/knowledge/list-knowledge)
# Delete Knowledge Document
Source: https://docs.instaview.sk/api-reference/agents/knowledge/delete-knowledge
DELETE /agents/{id}/knowledge/{documentId}
Permanently deletes the document, the stored file and the copy held by the voice provider. This cannot be undone.
Permanently deletes a document from an agent's knowledge base. **This action cannot be undone.**
## Overview
Deletion removes the document record, the stored file and the copy held by the voice provider. There is no soft-delete and no version history to recover from, which is what makes this a genuine erasure rather than a hidden retention.
The freed slot is available immediately.
## Example
```bash theme={null}
curl -X DELETE https://api.instaview.sk/agents/$AGENT_ID/knowledge/$DOCUMENT_ID \
-H "Authorization: Bearer $INSTAVIEW_API_KEY"
```
```json theme={null}
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"deleted": true
}
```
This is the response shape after the [August 3, 2026 envelope
change](/changelog/2026-08). Until that cutover, integrations still receiving the legacy
envelope get `{ "statusCode": 200, "message": "Success", "data": true, "traceId": "…" }`
instead. Watch for the `Deprecation` and `Sunset` headers to tell which shape you are on.
## Pending Documents
Works on `PENDING` documents too. Deleting one is how you release a reserved slot straight away instead of waiting for the 24-hour cleanup.
To stop using a document without losing it, [deactivate
it](/api-reference/agents/knowledge/update-knowledge) instead.
## Error Scenarios
* **404 Not Found**: No such document on that agent
## Related Resources
* [Update Knowledge Document](/api-reference/agents/knowledge/update-knowledge)
* [List Knowledge Documents](/api-reference/agents/knowledge/list-knowledge)
# List Knowledge Documents
Source: https://docs.instaview.sk/api-reference/agents/knowledge/list-knowledge
GET /agents/{id}/knowledge
Lists every document attached to the agent's knowledge base, including ones whose upload has not been completed yet (`status: PENDING`).
Lists every document attached to an agent's knowledge base.
## Overview
Returns documents oldest first, including ones whose upload has not been completed yet. The response is not paginated — an agent holds at most 10 documents.
## Reading the Response
| Field | Meaning |
| - | - |
| `status` | `PENDING` while waiting for the file and its completion call; `READY` once verified |
| `isActive` | Whether the agent may consult the document on a call. Always `false` while `PENDING` |
| `sizeBytes` | Size as verified against storage. Absent until the document is `READY` |
| `total` | Documents currently attached, including pending ones |
| `limit` | Maximum documents one agent may hold |
A `PENDING` document still counts against `limit`. If `total` has reached
`limit` and some documents are pending, either complete or delete them to free
a slot — abandoned uploads are reclaimed automatically after 24 hours.
## Example
```bash theme={null}
curl https://api.instaview.sk/agents/$AGENT_ID/knowledge \
-H "Authorization: Bearer $INSTAVIEW_API_KEY"
```
```json theme={null}
{
"data": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"title": "Benefits and perks",
"fileName": "benefits-and-perks.pdf",
"mimeType": "application/pdf",
"sizeBytes": 482133,
"status": "READY",
"isActive": true,
"createdAt": "2026-08-08T10:15:00.000Z"
}
],
"total": 1,
"limit": 10
}
```
## Related Resources
* [Start Knowledge Upload](/api-reference/agents/knowledge/start-upload)
* [Update Knowledge Document](/api-reference/agents/knowledge/update-knowledge)
* [Delete Knowledge Document](/api-reference/agents/knowledge/delete-knowledge)
# Start Knowledge Upload
Source: https://docs.instaview.sk/api-reference/agents/knowledge/start-upload
POST /agents/{id}/knowledge
Reserves a document slot on the agent and returns a presigned URL to upload the file to. The file never passes through this API.
1. `POST` this endpoint with the file's name, MIME type and size.
2. `PUT` the raw file bytes to the returned `uploadUrl`, sending `Content-Type` exactly as returned in `contentType`. Do **not** send your API key with this request — the URL carries its own authorisation.
3. `POST /agents/{id}/knowledge/{documentId}/complete` to confirm the upload.
The document stays `PENDING`, and is never used on a call, until step 3 succeeds. Uploads that are never completed are cleaned up automatically after 24 hours and release the slot they held.
This endpoint has a lower rate limit than the rest of the API.
Reserves a slot in an agent's knowledge base and returns a presigned URL to upload the file to.
## Overview
A knowledge base is a set of documents the agent can look up mid-call — a job spec, a benefits summary, a pricing sheet, an FAQ. Attaching one is a three-step flow, because the file bytes go straight to storage and never pass through this API.
`POST /agents/{id}/knowledge` with the file's name, MIME type and size. You get back a `documentId` and an `uploadUrl`.
`sizeBytes` must be the file's **exact** byte count, not an estimate. Both it and the MIME type are signed into the upload URL.
`PUT` the raw file bytes to `uploadUrl`, sending `Content-Type` exactly as returned in `contentType`.
Do **not** send your API key with this request. The upload URL carries its own authorisation, and adding an `Authorization` header will cause storage to reject the upload.
Storage rejects the `PUT` with `403` if the `Content-Type` or the `Content-Length` differs from what was signed — so a file whose real size does not match the `sizeBytes` you declared will not upload. Most HTTP clients set `Content-Length` for you from the body.
`POST /agents/{id}/knowledge/{documentId}/complete`. The stored file is verified, and only then does the document become usable on calls.
## Example
```bash theme={null}
# 1. Reserve a slot
curl -X POST https://api.instaview.sk/agents/$AGENT_ID/knowledge \
-H "Authorization: Bearer $INSTAVIEW_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"fileName": "benefits-and-perks.pdf",
"mimeType": "application/pdf",
"sizeBytes": 482133,
"title": "Benefits and perks"
}'
# 2. Upload the bytes (no API key on this request)
# curl sets Content-Length from the file; it must match the sizeBytes you declared.
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/pdf" \
--data-binary @benefits-and-perks.pdf
# 3. Confirm
curl -X POST https://api.instaview.sk/agents/$AGENT_ID/knowledge/$DOCUMENT_ID/complete \
-H "Authorization: Bearer $INSTAVIEW_API_KEY"
```
## Supported File Types
PDF, DOC, DOCX, TXT, MD, CSV, TSV, JSON, XML and YAML. Files are stored in the format you upload them in — there is no conversion step, so nothing is lost in translation.
## Limits
| Limit | Value |
| - | - |
| Maximum file size | 20 MiB (20,971,520 bytes) |
| Documents per agent | 10 |
| Upload URL validity | 15 minutes |
| Unfinished uploads reclaimed after | 24 hours |
A reserved slot counts against the per-agent document limit even before the
file is uploaded. If you abandon an upload, the slot is released automatically
after 24 hours — or immediately, if you `DELETE` the document.
## Rate Limiting
This endpoint has a **lower rate limit than the rest of the API** — 6 requests per minute and 60 per hour, per API key — because each call reserves a document slot and, once completed, a stored file.
A `429` response carries a `Retry-After` header with the number of seconds to wait. Completing an upload is limited separately and more generously, so retrying a failed completion is cheap.
## Error Scenarios
* **400 Bad Request**: Unsupported file type, a size over 20 MB, or the agent already holds 10 documents
* **404 Not Found**: No agent with that id belongs to your API key's company
* **429 Too Many Requests**: Upload rate limit exceeded — wait for `Retry-After` seconds
## Related Resources
* [Complete Knowledge Upload](/api-reference/agents/knowledge/complete-upload)
* [List Knowledge Documents](/api-reference/agents/knowledge/list-knowledge)
* [Agents Guide](/guides/resources/agents)
# Update Knowledge Document
Source: https://docs.instaview.sk/api-reference/agents/knowledge/update-knowledge
PATCH /agents/{id}/knowledge/{documentId}
Activates or deactivates a document. Deactivating keeps the document but stops it being attached to new calls. Only completed (`READY`) documents can be toggled.
Activates or deactivates a document without deleting it.
## Overview
Deactivating keeps the document and its stored file — it simply stops being attached to new calls. Use it to park seasonal or draft material you expect to bring back, instead of deleting and re-uploading.
Calls already in progress are unaffected: a document is resolved when a call starts.
## Example
```bash theme={null}
curl -X PATCH https://api.instaview.sk/agents/$AGENT_ID/knowledge/$DOCUMENT_ID \
-H "Authorization: Bearer $INSTAVIEW_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "isActive": false }'
```
## Pending Documents
Only completed (`READY`) documents can be toggled. Activating a document whose file never arrived would produce something that lists as usable and fails when a call tries to read it, so it is rejected with a `409` instead.
## Error Scenarios
* **404 Not Found**: No such document on that agent
* **409 Conflict**: The document's upload has not been completed yet
## Related Resources
* [List Knowledge Documents](/api-reference/agents/knowledge/list-knowledge)
* [Delete Knowledge Document](/api-reference/agents/knowledge/delete-knowledge)
# List Agents
Source: https://docs.instaview.sk/api-reference/agents/list-agents
GET /agents
Lists agents for the API key's company with pagination.
Lists the agents belonging to the API key's company, with pagination.
## Overview
Returns every agent available to your company — useful for picking one before creating a conversation, and for auditing what you have.
## Use Cases
* **Find Existing Agents**: Locate an agent before scheduling a call
* **Agent Management**: Review and audit all configured agents
* **Bulk Operations**: Retrieve agents for scheduling calls in bulk
* **Agent Discovery**: Browse available agents by type or purpose
## Basic Usage
```javascript theme={null}
// List all agents (first page, 20 items)
GET /agents
```
## With Pagination
```javascript theme={null}
// Get specific page
GET /agents?page=2&limit=50
// Response includes pagination metadata
{
"data": [...],
"total": 25,
"page": 2,
"limit": 50,
"totalPages": 1
}
```
## Finding an Agent by Type
The endpoint is paginated, so walk every page before filtering — a company with more agents
than one page holds would otherwise lose the matches sitting on later pages.
```javascript theme={null}
async function findAgentByType(agentType) {
const matches = [];
let page = 1;
let totalPages = 1;
do {
const response = await fetch(
`https://api.instaview.sk/agents?page=${page}&limit=50`,
{
headers: { Authorization: `Bearer ${apiKey}` },
// Without a deadline a stalled request hangs this loop indefinitely.
signal: AbortSignal.timeout(10_000),
},
);
if (!response.ok) {
throw new Error(
`Listing agents failed: ${response.status} ${response.statusText}`,
);
}
const body = await response.json();
totalPages = body.totalPages;
matches.push(...body.data.filter((agent) => agent.type === agentType));
page += 1;
} while (page <= totalPages);
return matches;
}
// Find ONLINE agents
const onlineAgents = await findAgentByType("ONLINE");
```
## Company Scoping
All returned agents belong to your API key's company. ATS keys can filter by `companyId` to access agents from specific companies they manage.
## Related Resources
Learn about agent configuration and best practices
Create a new agent
Understand pagination best practices
# Refine a Composed Agent
Source: https://docs.instaview.sk/api-reference/agents/refine-agent
POST /agents/composer/{sessionId}/messages
Applies one plain-language change to the session's agent. Returns 202; poll `GET /agents/composer/{sessionId}` for the updated preview. One turn runs at a time: sending another while one is in flight is a 409. Still creates nothing.
Change the agent a composer session is holding, by saying what you want changed.
```javascript theme={null}
POST /agents/composer/9f8c1d2e-4b7a-4c3d-9e1f-2a5b6c7d8e90/messages
{ "message": "Also ask about relocation, and make the whole call Slovak." }
```
Like [designing one](/api-reference/agents/compose-agent), this answers **202** straight away and applies the change in the background:
```javascript theme={null}
{
"sessionId": "9f8c1d2e-4b7a-4c3d-9e1f-2a5b6c7d8e90",
"status": "processing",
"expiresAt": "2026-09-17T10:15:00.000Z"
}
```
Poll [`GET /agents/composer/{sessionId}`](/api-reference/agents/get-composer-session) for the updated preview, or send a `webhookId` when you start the session and be told instead.
**Still no agent.** Refining changes the preview the session is holding. Nothing exists in
your account until you post to [`POST /agents`](/api-reference/agents/create-agent).
## One turn at a time
A session applies one instruction at a time. Send another while one is still running and you get a `409` immediately, without spending anything — wait for the session to leave `processing` and send it again.
The session keeps showing the previous agent throughout. A refinement in flight does not take away the preview you already had: you can still read it, and still create it.
## You do not send the agent back
The session holds the agent, so a follow-up carries only your instruction. There is nothing to keep in sync on your side, and no way to accidentally send back a stale version of the conversation.
You can refine as many times as you like within the session's 30 days.
## What a message can do
Anything the visual builder can do to a custom agent — the composer and the in-app copilot are the same thing behind different doors:
| You say | What changes |
| - | - |
| "Also ask about relocation" | A question is added to the right part of the conversation |
| "Make it Slovak" | The language, and every message and prompt rewritten in it |
| "Drop the salary question" | That question is removed |
| "Be more direct, less chatty" | The agent's communication style |
| "Score the Go question as required" | How the call is scored afterwards |
| "Call it something shorter" | The agent's name |
| "Transfer strong candidates to +421 900 123 456" | A handoff step (`PHONE` agents only) |
**Renaming is only ever on request.** If you pinned a `name` when you started the session,
it stays pinned — the composer will not quietly relabel your agent while doing something
else.
## A rejected change leaves the agent alone
Every turn is validated the same way the create route validates a flow. If a change would break the conversation — emptying a required message, leaving a conditional with one branch — the session comes back `failed` with the reason, and **the agent is untouched**: no half-applied edit, and the preview you had before is still there to read, to create, or to try a different instruction against.
A rejected change is not free, though: the instruction reached the model before it was found to be unapplicable, so it counts against your rate limit like any other turn. Only a `409` — refused before any work started — costs nothing.
```javascript theme={null}
{
"sessionId": "9f8c1d2e-4b7a-4c3d-9e1f-2a5b6c7d8e90",
"status": "failed",
"agent": { "…": "the agent your last successful turn left" },
"error": {
"code": "COMPOSER_INVALID_RESULT",
"message": "Could not apply that change without breaking the conversation."
},
"expiresAt": "2026-09-17T10:15:00.000Z"
}
```
This is also what keeps the promise that the preview is postable: the agent a session hands you has passed the create route's checks at every step, not just at the start.
## Errors
These are the ways the *request* can fail. A change that fails after it has been accepted is not an error here — it is a `failed` session, described above.
| Status | When |
| - | - |
| `400` | `message` missing, empty, or over 4,000 characters |
| `404` | No live session with that id belongs to your company — including one that expired |
| `409` | A turn is already running on this session, or the session has no agent yet because its first turn has not finished (or failed) |
| `429` | Composer rate limit reached (10 per minute, 120 per hour) |
An unknown session, an expired one, and one belonging to another company are all a `404`.
They are deliberately indistinguishable.
**Ask for one thing at a time.** A single instruction is applied faster than a compound one,
and is much easier to review afterwards.
# Update Agent
Source: https://docs.instaview.sk/api-reference/agents/update-agent
PATCH /agents/{id}
Updates an existing agent scoped to the API key's company. Sending a `flow` makes the agent custom; clearing the flow of a custom agent, or changing its focus, is rejected.
Updates an existing agent. Only the fields you send are changed.
## Overview
The update agent endpoint allows you to modify agent configuration without recreating it. This supports partial updates, meaning you only need to provide the fields you want to change. This is useful for refining agent questions, updating evaluation criteria, or adjusting voice settings.
## Use Cases
* **Refine Questions**: Change what the agent asks
* **Adjust Settings**: Change voice, duration or type
* **Update Criteria**: Modify evaluation criteria as requirements evolve
* **Iterative Improvement**: Continuously improve agent configurations
## Partial Updates
You can update any combination of fields:
```javascript theme={null}
// Update only questions
PATCH /agents/{id}
{
"questions": [
"New question 1",
"New question 2"
]
}
// Update multiple fields
PATCH /agents/{id}
{
"name": "Updated Agent Name",
"focus": "SCREENING",
"duration": 45,
"questions": [
"Updated question 1",
"Updated question 2"
]
}
```
## Fields That Can Be Updated
* `name`: Agent display name
* `type`: Agent type (ONLINE, PHONE)
* `focus`: Agent focus (GENERIC, SCREENING, OUTREACH, LANGUAGE\_TEST)
* `language`: language code for the call
* `duration`: call duration in minutes (max 180)
* `questions`: the questions to ask
* `instructions`: additional instructions for the agent
* `voiceId`: the voice the agent speaks with
* `cefrLevel`: CEFR level for language test agents
* `backgroundSound`: Ambient background sound during calls (`OFFICE` or `OFF`)
* `flow`: Conversation flow graph — see [Custom Agents](#custom-agents) below
* `guardrails`, `contextConfig`, `analyticsConfig`, `callConfig`: Custom agent configuration
* `overrides`: The company the agent speaks as — see [Speaking for another company](#speaking-for-another-company) below
## Custom Agents
Sending a `flow` replaces the agent's conversation design, and makes the agent custom if it was not already. See [Create Agent](/api-reference/agents/create-agent#custom-agents) for the shape of a flow and the rules that come with it — in particular, 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.
A configuration-only update like this one requires an agent that is **already** custom — see the third rule below:
```javascript theme={null}
PATCH /agents/{id}
{
"guardrails": { "rules": ["Never quote a price"] }
}
```
Three rules apply once an agent is custom:
* **`focus` cannot be changed** (`422`). A custom agent's prompts come from its flow; there is no template focus to move it to. Create a new agent instead.
* **The flow cannot be cleared** (`422`). Sending `"flow": null` on a custom agent would leave it with nothing to run.
* **`guardrails`, `contextConfig`, `analyticsConfig` and `callConfig` apply only to custom agents** (`422` otherwise). Send a `flow` in the same request to make the agent custom.
### Updating analytics
`analyticsConfig` replaces the stored config outright; send `null` to remove it.
**Replaced means replaced, one level down as well.** The update is partial at the top level
— an `analyticsConfig` you omit is left alone — but the object itself is not merged into the
stored one. Sending `analyticsConfig` with only `extractionTargets` in it clears
`scoringCriteria`, `outcomes`, `outputTags` and `capture`, because they are absent from the
config you sent.
```javascript theme={null}
// stored: extractionTargets + outputTags
PATCH /agents/{id}
{ "analyticsConfig": { "extractionTargets": [{ "label": "Budget", "type": "bool" }] } }
// stored now: extractionTargets ONLY — outputTags is gone
```
To change one list, send the others back unchanged. Read the agent first and edit the
`analyticsConfig` you get back — its keys round-trip, so resending it is safe.
What happens to **per-question scoring** — which lives under question ids you never see — depends on which of the two fields you send:
| You send | Per-question scoring |
| - | - |
| Neither | Untouched. |
| `analyticsConfig` only | Carried forward. The flow is unchanged, so its question ids are too. |
| `flow` only | Rebuilt from the new flow's inline `importance` / `weight`; the rest of the config is left alone. |
| Both | Rebuilt from the request, end to end. |
Replacing a `flow` re-mints every question id, so scoring that referred to the old
questions cannot survive as-is. Restate `importance` / `weight` inline on the questions of
the flow you send, exactly as you would on create — otherwise the questions in the new
flow are asked but not scored.
A `flow` you send is validated here exactly as it is on create, and an invalid one is rejected with a `422` carrying an `errors` array — every problem found, each with the `path` of the block it is on. See [Flow Validation Errors](/api-reference/agents/create-agent#flow-validation-errors) for the shape. The three rules above are `422`s too, but carry a `message` only, so treat `errors` as optional.
### Updating retries and calling hours
`callConfig` is the one config field that behaves the opposite way to the others. It is **merged**, and it is **not nullable**.
`retryPolicy` and `callWindow` are each replaced when you send them and left alone when you do not, so changing the attempt count does not mean restating the window:
```javascript theme={null}
{ "callConfig": { "retryPolicy": { "maxAttempts": 5 } } }
```
A `callWindow` you do send is replaced whole — send `days`, `start`, `end` and `timezoneAnchor` together. Half a window is how an agent ends up dialling at an hour nobody chose.
**`"callConfig": null` is rejected.** Every agent dials on some schedule, so there is no
"no policy" state to clear to — the only thing it could mean is falling back to the legacy
`business_hours` preset, which is not something to end up with by accident. To widen or
narrow the schedule, send the window you want.
Sending a `callWindow` is also how an agent still reporting `"mode": "business_hours"` is moved onto a real window. See [Retries and calling hours](/api-reference/agents/create-agent#retries-and-calling-hours) for the fields and for what the timezone anchor decides.
## Company Isolation
You can only update agents that belong to your API key's company. Attempting to update an agent from another company will result in a `403 Forbidden` error.
## Phone Agents
If you update an agent's `type` to `PHONE`, you **must** also provide a `companyPhoneNumberId` in the same request.
```javascript theme={null}
PATCH /agents/{id}
{
"type": "PHONE",
"companyPhoneNumberId": "123e4567-e89b-12d3-a456-426614174000"
}
```
## Background Sound
Use `backgroundSound` to change the ambient noise played during calls for this agent. Accepted values are `"OFFICE"` (subtle office noise, default) and `"OFF"` (silence).
```javascript theme={null}
PATCH /agents/{id}
{
"backgroundSound": "OFF"
}
```
Omitting `backgroundSound` keeps the existing value on the agent.
## Speaking for Another Company
`overrides` changes the company the agent introduces itself as. See [Create Agent](/api-reference/agents/create-agent#speaking-for-another-company) for what it does and does not change.
```javascript theme={null}
PATCH /agents/{id}
{
"overrides": {
"companyName": "Acme Manufacturing",
"companyDescription": "Acme makes industrial fasteners and employs 400 people across Slovakia."
}
}
```
**`overrides` is replaced whole, not merged** — the same contract as `guardrails` and
`contextConfig`. Sending only `companyName` drops any stored `companyDescription`, and the
agent falls back to your own company's description for it. Send every field you want kept,
every time.
Send `"overrides": null` to clear them; the agent goes back to speaking as your own company. Omitting the field leaves the stored overrides alone.
## Impact on Existing Conversations
Updating an agent does not affect conversations already scheduled or completed.
Only conversations created after the update use
the new configuration.
## Error Scenarios
* **404 Not Found**: Agent doesn't exist or has been deleted
* **403 Forbidden**: Agent belongs to a different company
* **400 Bad Request**: Invalid field values or validation errors
## Related Resources
Learn about agent configuration best practices
Retrieve current agent configuration
Create new agents
# Get Usage Statistics
Source: https://docs.instaview.sk/api-reference/billing/get-usage
GET /billing/usage
For direct client keys, returns usage for the associated company. For ATS keys, returns aggregated usage across ATS-managed companies. Test conversations (created with isTest=true) are excluded from billing and usage summaries.
Retrieves usage statistics for the API key's company: total minutes or credits consumed, the number of conversations, and a breakdown by cost key. Behaviour differs between regular API keys and ATS integration keys, and between companies on the **minutes** and **credits** billing systems.
## Overview
The get usage endpoint provides read-only access to billing and usage information. For regular API keys, it returns usage for the associated company. For ATS keys, it returns aggregated usage across all companies managed by that ATS key.
Each company in the response carries a `billingSystem` field indicating which unit it is billed in:
* `"minutes"` — legacy call-minutes accounting. `totalMinutesUsed` and `breakdownByCostKey[].minutesUsed` are populated; `totalCreditsUsed` and `creditsUsed` are omitted entirely.
* `"credits"` — per-product credits accounting. `totalCreditsUsed` and `breakdownByCostKey[].creditsUsed` are populated; `totalMinutesUsed` and `minutesUsed` are omitted entirely.
## Date Range Parameters
You can specify a date range for usage data:
```javascript theme={null}
// Last 30 days (default)
GET /billing/usage
// Custom date range
GET /billing/usage?start=2024-01-01&end=2024-01-31
```
* `start` (optional): Start date in ISO 8601 format (defaults to 30 days ago)
* `end` (optional): End date in ISO 8601 format (defaults to current date)
## Regular API Keys (Minutes-Based Company)
For minutes-based companies, the response reports usage in call minutes:
```json theme={null}
{
"companies": [
{
"companyId": "company-uuid",
"companyName": "Your Company",
"billingSystem": "minutes",
"totalMinutesUsed": 245.5,
"totalInterviews": 42,
"breakdownByCostKey": [
{
"costKey": "interview_minutes",
"minutesUsed": 245.5,
"events": 42
}
]
}
],
"totalMinutesUsed": 245.5,
"totalInterviews": 42,
"breakdownByCostKey": [
{
"costKey": "interview_minutes",
"minutesUsed": 245.5,
"events": 42
}
]
}
```
## Regular API Keys (Credits-Based Company)
For credits-based companies, the response reports usage in credits. Minutes-related fields are omitted entirely:
```json theme={null}
{
"companies": [
{
"companyId": "company-uuid",
"companyName": "Your Company",
"billingSystem": "credits",
"totalCreditsUsed": 612.5,
"totalInterviews": 42,
"breakdownByCostKey": [
{
"costKey": "interview_minutes",
"creditsUsed": 612.5,
"events": 42
}
]
}
],
"totalCreditsUsed": 612.5,
"totalInterviews": 42,
"breakdownByCostKey": [
{
"costKey": "interview_minutes",
"creditsUsed": 612.5,
"events": 42
}
]
}
```
## ATS Integration Keys
For ATS keys, the response includes aggregated usage across all companies managed by the ATS.
```json theme={null}
{
"companies": [
{
"companyId": "company-a-uuid",
"companyName": "Client Company A",
"billingSystem": "minutes",
"totalMinutesUsed": 120.0,
"totalInterviews": 20,
"breakdownByCostKey": [
{
"costKey": "interview_minutes",
"minutesUsed": 120.0,
"events": 20
}
]
},
{
"companyId": "company-b-uuid",
"companyName": "Client Company B",
"billingSystem": "minutes",
"totalMinutesUsed": 80.5,
"totalInterviews": 22,
"breakdownByCostKey": [
{
"costKey": "interview_minutes",
"minutesUsed": 80.5,
"events": 22
}
]
}
],
"totalMinutesUsed": 200.5,
"totalInterviews": 42,
"breakdownByCostKey": [
{
"costKey": "interview_minutes",
"minutesUsed": 200.5,
"events": 42
}
]
}
```
## Use Cases
* **Usage Monitoring**: Track minutes or credits spent, and how many conversations ran
* **Cost Tracking**: Monitor usage against subscription limits
* **Billing Reporting**: Generate usage reports for billing purposes
* **Multi-Tenant Analytics**: Track usage per company for ATS platforms
## Response Structure
The response includes:
* **companies**: Array of company-level usage (single item for regular keys, multiple for ATS keys).
* **billingSystem** *(per company)*: Either `"minutes"` or `"credits"`.
* **totalMinutesUsed**: Total call minutes consumed in the period. Present when at least one company in scope is on the minutes billing system; omitted otherwise.
* **totalCreditsUsed**: Total credits consumed in the period. Present when at least one company in scope is on the credits billing system; omitted otherwise.
* **totalInterviews**: How many conversations ran. The field keeps its original name — see [Resource names](/api-reference/introduction#resource-names) — as does the `interview_minutes` cost key.
* **breakdownByCostKey**: Array of usage buckets. Each entry includes `costKey` and `events`, plus `minutesUsed` *or* `creditsUsed` depending on the company's billing system. Cross-system fields are never present in a per-company breakdown.
## Monitoring Usage
```javascript theme={null}
async function checkUsageStatus() {
const response = await fetch("https://api.instaview.sk/billing/usage", {
headers: { Authorization: `Bearer ${apiKey}` },
});
const data = await response.json();
data.companies.forEach((company) => {
if (company.billingSystem === "credits") {
console.log(
`${company.companyName}: ${company.totalCreditsUsed} credits`,
);
} else {
console.log(`${company.companyName}: ${company.totalMinutesUsed} min`);
}
});
return data;
}
```
## Required Scope
**Required Scope**: `read:billing` is required to access this endpoint. Ensure
your API key has this scope enabled.
## Related Resources
Comprehensive guide to billing, usage tracking, and subscription management
Learn about required scopes
Understand ATS key usage aggregation
# Create Company
Source: https://docs.instaview.sk/api-reference/companies/create-company
POST /company
Creates a new company using an ATS API key. Direct client keys cannot create companies via this endpoint.
Creates a new company. This endpoint is **only available for ATS integration keys** and is used for multi-tenant ATS platforms managing multiple client companies.
## Overview
The create company endpoint allows ATS integration keys to create new companies in the InstaView system. This is essential for multi-tenant ATS platforms that need to onboard new clients and manage their data in isolation.
## ATS Key Requirement
**ATS Keys Only**: This endpoint requires an ATS integration key. Regular API keys cannot create companies and will receive a `403 Forbidden` error. ATS keys must be created by InstaView administrators.
## Use Cases
* **Client Onboarding**: Create companies for new ATS clients
* **Multi-Tenant Management**: Set up company isolation for ATS platforms
* **White-Label Solutions**: Create companies for white-label integrations
* **Platform Integration**: Onboard companies from external ATS systems
## Basic Company Creation
```javascript theme={null}
{
"name": "Client Company Name",
"website": "https://clientcompany.com",
"industry": "Technology"
}
```
## ATS Integration Workflow
```javascript theme={null}
// 1. Create company for new ATS client
const company = await createCompany({
name: "Client Company",
website: "https://client.com"
});
// 2. Create jobs for this company
await createJob(company.id, jobData);
// 3. Create contacts for the jobs
await createContact(company.id, contactData);
```
## Company Isolation
Each company's data is completely isolated. Companies created by an ATS key can only be accessed by that ATS key (or by regular API keys belonging to that company).
## Related Resources
Learn about ATS integration keys and multi-tenant management
Understand company management
View all companies managed by your ATS key
# Delete Company
Source: https://docs.instaview.sk/api-reference/companies/delete-company
DELETE /company/{id}
Permanently deletes a company. This action is irreversible. ATS keys can delete companies they created; direct client keys can delete their own company.
Permanently deletes a specific company by ID. Access rules differ between regular API keys and ATS integration keys.
## Overview
This endpoint permanently removes a company and everything belonging to it — jobs, contacts, conversations and the rest. It is irreversible.
## Access Rules
### Regular API Keys
Regular API keys can only delete their associated company:
```javascript theme={null}
// ✅ Can delete own company
DELETE /company/{own-company-id}
// ❌ Cannot delete other companies
DELETE /company/{other-company-id}
// Returns: 403 Forbidden
```
### ATS Integration Keys
ATS keys can delete any company they've created:
```javascript theme={null}
// ✅ Can delete companies created by this ATS key
DELETE /company/{ats-managed-company-id}
// ❌ Cannot delete companies not managed by this ATS key
DELETE /company/{unmanaged-company-id}
// Returns: 403 Forbidden
```
## Related Resources
Learn about ATS integration keys and access rules
Understand company management
# Get Company
Source: https://docs.instaview.sk/api-reference/companies/get-company
GET /company/{id}
Returns a single company. Direct client keys can only access their own company; ATS keys can only access companies they created.
Retrieves a specific company by ID. Access rules differ between regular API keys and ATS integration keys.
## Overview
The get company endpoint returns complete information about a single company, including name, website, industry, and settings. Regular API keys can only access their own company, while ATS keys can access companies they've created.
## Access Rules
### Regular API Keys
Regular API keys can only access their associated company:
```javascript theme={null}
// ✅ Can access own company
GET /company/{own-company-id}
// ❌ Cannot access other companies
GET /company/{other-company-id}
// Returns: 403 Forbidden
```
### ATS Integration Keys
ATS keys can access any company they've created:
```javascript theme={null}
// ✅ Can access companies created by this ATS key
GET /company/{ats-managed-company-id}
// ❌ Cannot access companies not managed by this ATS key
GET /company/{unmanaged-company-id}
// Returns: 403 Forbidden
```
## Use Cases
* **Company Profile Display**: Show company information in your UI
* **Access Verification**: Verify company access before operations
* **Company Details**: Retrieve company settings and configuration
## Response Data
The response includes company information:
* Basic details (name, website, industry)
* Company size and location
* Settings (timezone, locale)
* Timestamps (created, updated)
## Error Scenarios
* **404 Not Found**: Company doesn't exist
* **403 Forbidden**: Company is not accessible with your API key type
## Related Resources
Learn about ATS integration keys and access rules
Understand company management
Modify company information
# List Companies
Source: https://docs.instaview.sk/api-reference/companies/list-companies
GET /company
For direct client API keys, returns the single associated company. For ATS keys, returns companies created by this API key.
Lists companies accessible to the API key. Behavior differs between regular API keys and ATS integration keys.
## Overview
The list companies endpoint returns companies based on your API key type. Regular API keys see only their associated company, while ATS keys see all companies they've created.
## Regular API Keys
For regular API keys, this endpoint returns the single company associated with the key:
```json theme={null}
{
"data": [
{
"id": "company-uuid",
"name": "Your Company"
}
],
"total": 1,
"page": 1,
"limit": 20,
"totalPages": 1
}
```
## ATS Integration Keys
For ATS keys, this endpoint returns all companies created by that ATS key:
```json theme={null}
{
"data": [
{
"id": "company-a-uuid",
"name": "Client Company A"
},
{
"id": "company-b-uuid",
"name": "Client Company B"
}
],
"total": 2
}
```
## Use Cases
* **Company Discovery**: Find company IDs for ATS integrations
* **Multi-Tenant Management**: List all client companies for ATS platforms
* **Company Verification**: Verify company access and existence
## Pagination
The endpoint supports pagination for ATS keys managing many companies:
```javascript theme={null}
GET /company?page=1&limit=50
```
## Related Resources
Learn about ATS integration keys
Understand company management
Retrieve a specific company by ID
# Update Company
Source: https://docs.instaview.sk/api-reference/companies/update-company
PATCH /company/{id}
Updates company details. ATS keys can update companies they created; direct client keys can update their own company.
Updates an existing company using partial updates. Access rules differ between regular API keys and ATS integration keys.
## Overview
The update company endpoint allows you to modify company information without recreating it. This supports partial updates, meaning you only need to provide the fields you want to change. Regular API keys can update their own company, while ATS keys can update companies they've created.
## Access Rules
### Regular API Keys
Regular API keys can only update their associated company:
```javascript theme={null}
// ✅ Can update own company
PATCH /company/{own-company-id}
{
"name": "Updated Company Name"
}
```
### ATS Integration Keys
ATS keys can update any company they've created:
```javascript theme={null}
// ✅ Can update companies created by this ATS key
PATCH /company/{ats-managed-company-id}
{
"name": "Updated Client Company Name"
}
```
## Use Cases
* **Company Profile Updates**: Update company name, website, or industry
* **Settings Management**: Modify company settings (timezone, locale)
* **Client Management**: Update client company information for ATS platforms
## Partial Updates
You can update any combination of fields:
```javascript theme={null}
// Update only name
PATCH /company/{id}
{
"name": "New Company Name"
}
// Update multiple fields
PATCH /company/{id}
{
"name": "Updated Name",
"website": "https://newwebsite.com",
"industry": "Technology"
}
```
## Error Scenarios
* **404 Not Found**: Company doesn't exist
* **403 Forbidden**: Company is not accessible or updatable with your API key type
* **400 Bad Request**: Invalid field values or validation errors
## Related Resources
Learn about ATS integration keys
Understand company management
Retrieve current company information
# Create Contact Field
Source: https://docs.instaview.sk/api-reference/contact-fields/create-contact-field
POST /contact-fields
Creates a field for your company. A key matching a seeded field shadows it for you alone; a key the platform reserves for itself is refused. A company may define at most 50 fields.
Defines a new field your contacts can carry — and your agents can speak.
## Overview
Creating a field is a **schema** change, not a data change: from this point on the key is
offered in the agent designer's variable palette, accepted by
[`PATCH /contacts/{id}`](/api-reference/contacts/update-contact), and available
as a CSV import target. Anyone who can write your contacts can define one.
```javascript theme={null}
POST /contact-fields
{
"key": "order_number",
"label": "Order number",
"dataType": "string",
"description": "The order this call is about",
"csvAliases": ["Order #", "OrderNo"],
"allowPiiInContext": false,
"displayOrder": 50
}
```
## Choosing a key
The key becomes the prompt token `{{contact.}}`, so it has to be something the token
grammar can express: **lowercase letters, digits and underscores, starting with a letter**, up
to 80 characters. `order_number` is fine; `Order-Number`, `2nd_order` and `order number` are
each a `400`.
**A key cannot be changed later.** It is the identity of every `{{contact.}}` token
already saved inside an agent's flow *and* of every value already stored on a contact, and
those cannot be migrated in one step — a rename would blank the tokens and orphan the values.
[`PATCH`](/api-reference/contact-fields/update-contact-field) has no `key` property at all.
Choose the key as deliberately as a column name.
Some keys are **reserved**, because the platform resolves them itself on every call:
`first_name`, `last_name`, `email`, `phone`, `company_name`, `company_description`,
`agent_name` and `profile`. Attempting one is a `400` naming it, rather than a field that is
created and then never spoken.
Every other seeded key is **yours to shadow**. `order_number`, `job_title` and `lead_source`
are platform fields, but creating a company field with the same key is legal and meaningful —
it overrides the label and type for your company alone, and the response comes back with
`isCompanyOverride: true`. So `isSystem: true` does not mean "cannot be used": it means the
platform seeded it, and only the eight keys above are actually refused.
**This is the intended path for the three enum-shaped seeded fields.** `company_size`,
`industry` and `lead_status` are seeded as `string`, because one company's lead statuses are
not another's and platform options would be wrong for nearly everybody. Shadow the key with
an `enum` carrying your own `enumOptions` and you get the constraint without losing the CSV
aliases the seeded field brought with it. The `category` stays the seeded one, so the field
does not move panels in the dashboard.
## Choosing a type
| `dataType` | Accepts | Stored as |
| - | - | - |
| `string` | any text | trimmed text |
| `number` | `42`, `"42"`, `"-7.5"` | a number |
| `bool` | `true`, `"true"`, `"yes"`, `"1"` (and their negatives) | a boolean |
| `date` | anything `Date` can parse, e.g. `"2026-04-20"` | an ISO 8601 timestamp |
| `enum` | one of your own `enumOptions` | the matching option |
| `tags` | `["vip","renewal"]`, **or** `"vip, renewal"` | a `string[]`, trimmed and de-duplicated |
`tags` is for an **open-ended** set of labels — segments, flags, anything a person adds to as
they go. Reach for `enum` instead when the set is closed and you want a value outside it
refused. `tags` is also the one type that takes two shapes on the way in, an array or a
comma-separated string, so a single CSV cell holding several tags maps onto one field. A comma
is always a separator, so a tag cannot contain one — that holds inside an array element too. A
value that trims down to nothing clears the field, the way `""` does for text.
Bounded three ways, and the third is the one that catches people: at most **50 tags**, **60
characters** per tag, and **2000 characters** of tag text per request — so the first two are
not independent, and fifty sixty-character tags is over the aggregate. Each limit is a `422`
naming it. See
[Set Contact Fields](/api-reference/contacts/update-contact#tags-takes-two-shapes) for the
full write contract.
`enum` **requires** `enumOptions`, and the options must be unique. An enum with no options
would accept anything while claiming to be a closed set, and an import mapped against it would
pass preview and then fail row by row — so it is refused at creation instead.
Coercion is deliberately narrow. A CSV and a JSON body disagree about types for the same field,
so a string that unambiguously denotes the declared type is accepted and **nothing else is
guessed**: `"twelve"` is not a number, and `"maybe"` is not a boolean.
## Marking personal data
Set `isPii: true` for anything that identifies a person. The value is then masked in logs, and
it is **excluded** from what reaches an agent unless you also set `allowPiiInContext`.
It is not forbidden, though. Setting both is a deliberate act with a real use: for a collections
agent the amount owed is the entire reason for the call, and an agent that cannot name it is
worse than one that can. What you are choosing when you set both is to put that value in the
prompt of every call for a contact who has one — so choose it per field, and leave it off
otherwise.
## Limits
A company may define at most **50** fields of its own. Seeded fields do not count against it.
## Scopes
`write:contacts` (or its `write:candidates` alias).
A field with `allowPiiInContext` is summarised into the prompt of every call for a
contact that has a value for it, so defining fields does shape what your agents say. It is
not behind a scope of its own: the keys that can write your contacts are your own and your
ATS partner's, and a separate grant would be one more thing to discover without narrowing
who actually holds it.
## Error Scenarios
* **400 Bad Request**: a malformed key; a key you have already defined; a key the platform
reserves; an `enum` with no options or with duplicates; more than 50 fields
* **403 Forbidden**: the key lacks `write:contacts`
## Related Resources
See the catalog, seeded fields included
Change everything except the key
Store values against a contact
Remove a definition, keep the values
# Delete Contact Field
Source: https://docs.instaview.sk/api-reference/contact-fields/delete-contact-field
DELETE /contact-fields/{id}
Removes the definition. The values already stored on your contacts are LEFT IN PLACE and simply stop being returned, so nothing is irreversibly lost — define the key again and they reappear. What the delete does break is any agent flow still using `{{contact.}}`: the token resolves to an empty string from now on, and the response names those agents.
Removes one of your contact-field definitions. The values stay where they are.
## Overview
```javascript theme={null}
DELETE /contact-fields/{id}
```
```json theme={null}
{
"id": "9c22...",
"key": "order_number",
"deleted": true,
"referencedBy": [
{ "id": "a1b2...", "name": "Renewal outreach" }
]
}
```
## What is and is not lost
**The stored values are not deleted.** They stop being returned by
[Get Contact](/api-reference/contacts/get-contact), because that endpoint
filters to keys the catalog still knows. Define the same key again and the values reappear.
Scrubbing them would mean two losses for one action — the token stops resolving *and* the data
is gone — and only one of those is reversible.
What the delete **does** break is any agent still using the token. `{{contact.order_number}}`
in a saved flow resolves to an empty string from now on, and nothing else in the system would
have told you. So the response names those agents in `referencedBy`.
Treat a non-empty `referencedBy` as work to do: open each agent and remove or replace the token.
An empty array means nothing referenced the field.
## Seeded fields cannot be deleted
A field with `isSystem: true` belongs to the platform. `DELETE` on one is a `403`. If you had
defined your own field shadowing it, deleting yours simply reveals the seeded field again.
## Scopes
`write:contacts` — **not** `delete:contacts`. This removes schema, not a contact, and the
values already stored deliberately survive it; requiring the delete scope would imply the
opposite.
## Error Scenarios
* **403 Forbidden**: a seeded field; or the key lacks `write:contacts`
* **404 Not Found**: no such field in your company's catalog
## Related Resources
Find the field's id
Change it instead of removing it
# List Contact Fields
Source: https://docs.instaview.sk/api-reference/contact-fields/list-contact-fields
GET /contact-fields
Every field your contacts can carry: the platform's seeded fields merged with your own, which shadow a seeded field of the same key. Read this to learn which keys `fields` accepts on `POST /contacts` and `PATCH /contacts/{id}`, and what each one expects.
Lists every field your contacts can carry, and what each one expects.
## Overview
A contact field is a typed, named slot on a contact — `order_number`, `lead_source`,
`renewal_date`. Two things make a field different from a note you keep on your own side:
1. **An agent can speak it.** `{{contact.order_number}}` in an agent's prompt is replaced with
this contact's value at the moment the call is placed.
2. **The platform validates it.** A field declared `number` rejects `"twelve"` on the way in,
rather than surfacing as a confused agent mid-call.
Read this endpoint first. It tells you which keys
[`PATCH /contacts/{id}`](/api-reference/contacts/update-contact) will accept
and how each value must be shaped.
**The catalog is thirty-six fields, not eleven.** InstaView seeds a default set covering
identity, the contact's employer, orders and invoices, the relationship, appointments and
screening — so most of what you would have defined by hand already exists, with CSV aliases
attached. If you built a form or a table that renders every field it gets back, expect
thirty-six rows rather than a handful, and read `category` if you want them grouped the way
the dashboard groups them.
## What you get back
Two kinds of field, merged into one list:
| | Where it comes from | Can you change it? |
| - | - | - |
| `isSystem: true` | Seeded by InstaView for every company | No — but most can be shadowed |
| `isSystem: false` | Defined by you | Yes |
A seeded field is the platform's contract with the prompt compiler and the CSV importer, so
one company editing it directly would change what its token means for everybody. To change a
seeded field's label or type **for your company alone**, define your own field with the same
key: it shadows the seeded one, and comes back with `isCompanyOverride: true`.
**Eight keys are RESERVED and cannot be shadowed at all**: `first_name`, `last_name`,
`email`, `phone`, `company_name`, `company_description`, `agent_name` and `profile`. A
`POST /contact-fields` on one of those is refused — the platform resolves them itself, so a
company definition would be advertised in the palette and then ignored on the call.
`isSystem: true` alone does not tell you which is which, because most seeded keys
(`order_number`, `is_vip`, `lead_source`) *can* be shadowed. Check `writable` if what you
need to know is whether you can send a value for a key.
```javascript theme={null}
GET /contact-fields
```
```json theme={null}
{
"data": [
{
"id": "3f1b...",
"key": "first_name",
"label": "First name",
"dataType": "string",
"category": "identity",
"isPii": true,
"isSystem": true,
"displayOrder": 10,
"isCompanyOverride": false,
"readOnly": false,
"writable": false
},
{
"id": "9c22...",
"key": "renewal_date",
"label": "Renewal date",
"dataType": "date",
"category": "commercial",
"csvAliases": ["renewal", "renewal date", "renews", "dátum obnovenia"],
"isPii": false,
"isSystem": true,
"displayOrder": 50,
"isCompanyOverride": false,
"readOnly": false,
"writable": true
}
],
"total": 36
}
```
## Reading the flags
One of `string`, `number`, `date`, `bool`, `enum` or `tags`. A write is checked against it: a
`number` field takes `42` or `"42"` but not `"twelve"`; a `bool` field takes `true`,
`"true"`, `"yes"` or `"1"` and their negatives, but not `"maybe"`; a `date` field is stored
as an ISO timestamp; an `enum` field takes only a value from its own `enumOptions`.
A `tags` field stores a `string[]` and is the one type that accepts two shapes: an array,
**or** a comma-separated string, so one CSV cell holding several tags maps onto one field.
See [Set Contact Fields](/api-reference/contacts/update-contact#tags-takes-two-shapes)
for what it trims, de-duplicates and treats as an erase.
One of `identity`, `organisation`, `commercial`, `relationship`, `scheduling`,
`recruiting`, `platform` or `custom`. It is the grouping the dashboard's variable palette
uses, and the first key this list is ordered by — so iterating the response and breaking on
a change of `category` gives you the same panels a user sees.
Set by InstaView and not writable. A field you define is always `custom`; a field of yours
that shadows a seeded one reports the **seeded** field's category, so `job_title` stays
under `organisation` for everybody even after you relabel it.
A PII field is masked in logs and is **excluded** from everything an agent gets: it is
neither in the prompt nor in the document the agent can query. That is what keeps `email`
and `phone` off every call without anyone deciding, and it is enforced rather than
defaulted.
Two things get past it. `inEssentialPromptSet` below, which is ours — `first_name` is
personal data and is also the first word of the call. And `allowPiiInContext`, which is
yours: set it on the one field an agent has to be able to say, and the value joins both the
document and the prompt. For a collections agent the amount owed is the entire reason for
the call. Setting it on a field that is not `isPii` is a `400`, not a no-op.
Your decision that this particular personal datum belongs on a call. Writable, and only
meaningful on an `isPii` field. See
[Create Contact Field](/api-reference/contact-fields/create-contact-field).
Read-only, and ours to decide. A short set of fields goes into the prompt of every
context-enabled agent, because a prompt line is always known while a document entry is known
only if the agent thinks to look. Everything else a contact has a value for reaches the
agent through the generated contact document instead, which costs nothing until it is
queried.
Both halves are gated by
[`contextConfig.useContactContext`](/api-reference/agents/create-agent#telling-the-agent-about-the-person-it-is-calling)
on the agent. There is no per-field opt-in to maintain: define fields, store values, and an
agent with the switch on can use them.
A handful of fields exist so an author can use the token, but resolve from somewhere other
than the contact: `company_description` is your company's own blurb, `agent_name` is the
agent's. Sending a value for one is a `422` naming the key. They are listed here because an agent author
still needs to see them.
**The flag to branch on before a write**, and not the same thing as `!readOnly`. Look at
`first_name` in the response above: `readOnly` is `false`, and a value for it is still
refused with a `422`. It is one of the keys the platform RESERVES — the identity fields
(`first_name`, `last_name`, `email`, `phone`) plus `company_name`, `company_description`,
`agent_name` and `profile`. Those four identity fields *are* writable, just as properties of
[the contact itself](/api-reference/contacts/update-contact) rather than as fields.
So `readOnly` answers "where does this value come from" and `writable` answers "will this
endpoint take one from me". `writable: false` always means do not send a value for this key;
it covers every `readOnly` field as well as every reserved one.
Header names an import should also match this field on, beyond the key and label — so a
sheet whose column is called `Order #` can be recognised as `order_number` rather than
mapped by hand.
Every writable seeded field arrives with aliases in English, Slovak and Czech —
`renewal_date` carries `renewal`, `renews`, `dátum obnovenia` and more — ready for the
importer to match on. Adding your own with
[`PATCH /contact-fields/{id}`](/api-reference/contact-fields/update-contact-field) does not
touch InstaView's. Read the warning below first: nothing consumes any of them yet.
**Declared, but nothing consumes it yet.** The current CSV importer maps against a fixed
list of built-in contact properties and does not read this array, so setting
`csvAliases` today changes no import behaviour. It is stored so the catalog is complete
before the contact-field import lands. Until then, map these columns explicitly.
`id` exists only to address `PATCH` and `DELETE`. Everywhere else — a prompt token, a
values payload, a CSV mapping — a field is addressed by `key`.
## Ordering and paging
Ordered by `category` first — in the fixed order `identity`, `organisation`, `commercial`,
`relationship`, `scheduling`, `recruiting`, `platform`, `custom` — then by `displayOrder`, then
by `key`. `displayOrder` therefore only ever separates fields **within** one category, which is
why each category's own numbering restarts rather than continuing one long sequence.
Unpaginated: your whole catalog is smaller than one page of any other list endpoint, so there
is nothing to page through.
## Scopes
`read:contacts` (or its `read:candidates` alias). Changing the catalog needs `write:contacts`.
Nothing to rotate: any key that can already read your contacts can read their field catalog.
## Related Resources
Define a field of your own
Store values against a contact
Read one contact's values
Change one of your own fields
# Update Contact Field
Source: https://docs.instaview.sk/api-reference/contact-fields/update-contact-field
PATCH /contact-fields/{id}
Changes one of your own fields. Everything is editable except the key, which is fixed once the field exists. Seeded fields cannot be edited at all.
Changes one of your own contact fields. A partial update: only what you send changes.
## Overview
Everything about a field is editable except its key. Relabel it, retype it, add CSV aliases,
switch it in or out of the agent's contact summary, reorder it.
```javascript theme={null}
// Relabel
PATCH /contact-fields/{id}
{
"label": "Order reference"
}
// Turn a loose string into a closed set
PATCH /contact-fields/{id}
{
"dataType": "enum",
"enumOptions": ["pricing page", "webinar", "referral"]
}
// Start volunteering it to the agent
PATCH /contact-fields/{id}
{
"isPii": true,
"allowPiiInContext": true
}
// Match two more CSV headers on import
PATCH /contact-fields/{id}
{
"csvAliases": ["Order #", "OrderNo"]
}
```
## The key is not editable
There is no `key` property on this request. Sending one is a `400` naming it, rather than a
silent no-op.
The reason is that a key is the identity of two things that cannot be migrated in the same
step: every `{{contact.}}` token already saved inside an agent's flow, and every value
already stored under that key on your contacts. A rename would blank the first and orphan the
second. To change a key,
[create a new field](/api-reference/contact-fields/create-contact-field), copy the values
across, update your agents, then
[delete the old one](/api-reference/contact-fields/delete-contact-field).
## Seeded fields cannot be edited
A field with `isSystem: true` belongs to the platform — editing it would change what
`{{contact.first_name}}` means for every company. `PATCH` on one is a `403`. To change a seeded
field's label or type for your company alone, **define your own field with the same key**: it
shadows the seeded one and comes back with `isCompanyOverride: true`.
## Changing a type
Changing `dataType` changes how **future** writes are validated. It does **not** re-validate or
convert values already stored — those keep whatever they were written as. If you are tightening
a field (say `string` to `enum`), read the existing values first.
Moving away from `enum` clears `enumOptions` automatically, so a field cannot keep a stale
option list that validation would then enforce against a string.
`tags` is worth calling out for the same reason: switching a `string` field to `tags` leaves
every stored value a plain string until it is next written, so a reader has to handle both
until then. Writing `"a, b"` to it once turns that contact's value into `["a","b"]`.
## Scopes
`write:contacts` (or its `write:candidates` alias).
## Error Scenarios
* **400 Bad Request**: a `key` property; an `enum` left with no options
* **403 Forbidden**: a seeded field; or the key lacks `write:contacts`
* **404 Not Found**: no such field in your company's catalog
## Related Resources
Find the field's id
Define a new field
# Create Contact
Source: https://docs.instaview.sk/api-reference/contacts/create-contact
POST /contacts
Creates a contact (optionally associated with one or more jobs) in the API key's company.
`POST /candidates` is a permanent alias of this endpoint and keeps working unchanged, with the same scopes and the same response. See [Resource names](/api-reference/introduction#resource-names).
Creates a contact — a person an agent can call. A contact can optionally be associated with a job, or exist on its own.
## Overview
A contact is the person on the other end of a conversation: the record holding their name, how to reach them, and whatever else you want to keep about them. Jobs are optional and can be attached at creation or later.
## Use Cases
* **Application processing**: create contacts from inbound applications, with a job attached
* **ATS integration**: sync contacts in from an external system
* **Manual entry**: add people you found some other way
* **Building a list**: keep contacts with no job assignment at all
* **Bulk import**: load many contacts at once
## Job Association (Optional)
**A job is optional.** Send `jobId` to attach one at creation, or leave it out.
* **With a job**: the contact is associated with that job immediately.
* **Without one**: the contact exists on its own, and jobs can be attached later through the update endpoint. Most callers outside hiring never send one.
* If provided, the job must exist and belong to your API key's company.
## Basic Contact Creation
### With Job Association
```javascript theme={null}
{
"jobId": "job-uuid",
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com",
"phoneNumber": "+1234567890",
"gdprExpiryDate": "2026-11-16"
}
```
### Without Job Association
```javascript theme={null}
{
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com",
"phoneNumber": "+1234567890",
"gdprExpiryDate": "2026-11-16"
}
```
## A Fuller Contact
```javascript theme={null}
{
"jobId": "job-uuid",
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com",
"phoneNumber": "+1234567890",
"gdprExpiryDate": "2026-11-16",
"gender": "female",
"metadata": {
"source": "LinkedIn",
"externalId": "CONTACT-12345",
"notes": "Strong technical background",
"resumeUrl": "https://storage.example.com/resumes/jane-doe.pdf",
"linkedinUrl": "https://linkedin.com/in/janedoe"
},
"workHistory": [
{
"companyName": "Tech Corp",
"candidatePosition": "Software Engineer",
"referenceName": "Jane Smith",
"referencePhone": "+1234567890",
"startDate": "2020-01-01",
"endDate": "2022-12-31"
}
]
}
```
**Note**: `resumeUrl` and `linkedinUrl` are not built-in fields — they are shown inside the
free-form `metadata` object, which is where anything of your own belongs.
**`metadata` is not readable by an agent.** It is your own scratch space — free-form,
unvalidated, and never placed in a prompt. If you want a value an agent can *say*, it has to
be a **contact field**: catalogued, typed, and addressable as `{{contact.}}`. See
[Update Contact](/api-reference/contacts/update-contact).
`workHistory` describes previous employment, and its `candidatePosition` field keeps that name
because it is the name the API accepts. It exists for reference calls: each entry can carry a
`referenceName` and `referencePhone` to call about that job.
## Company Scoping
Contacts are scoped to your API key's company. When a job is attached, the job must belong to that company; a contact created without one is associated with your company anyway, and can only be reached through your company's API keys. That is what keeps companies fully isolated from each other.
## Required Fields
**Required on every contact**:
* `firstName` — 1-100 characters
* `lastName` — 1-100 characters
* `gdprExpiryDate` — GDPR retention date, ISO 8601, and in the future (e.g. `"2026-11-16"`)
* At least one way to reach them: `email` or `phoneNumber` (or both)
## Optional Fields
* **jobId**: UUID of a job to attach (must exist and belong to your company)
* **email**: valid email address (required if `phoneNumber` is absent)
* **phoneNumber**: E.164 format, e.g. `+1234567890` (required if `email` is absent)
* **gender**: `"male"` or `"female"`. Used for gender-aware addressing on the call. Auto-detected from the name when omitted.
* **metadata**: your own key-value pairs (max 10KB, 5 levels deep, 50 keys)
* **fields**: catalogued field values, keyed by catalog key — validated against the catalog, and the only values an agent can speak
## Field Validation
The API enforces:
* **firstName/lastName**: 1-100 characters (required)
* **gdprExpiryDate**: a valid ISO 8601 date in the future (required)
* **email**: valid email format (at least one of email or phoneNumber required)
* **phoneNumber**: starts with `+`, E.164 format (at least one of email or phoneNumber required)
* **jobId**: a valid UUID naming an existing job in your company (optional)
* **gender**: `"male"` or `"female"` if present (optional)
* **metadata**: max 10KB, max depth 5, max 50 keys (optional)
## Setting Field Values on the Create
Send them as `fields`, keyed by catalog key, and the contact is created with its values in one
request:
```javascript theme={null}
POST /contacts
{
"firstName": "Jane",
"lastName": "Doe",
"email": "jane@example.com",
"fields": {
"is_vip": true,
"order_number": "SO-40128",
"renewal_date": "2026-04-01"
}
}
```
The response's `fields` is what was stored, so there is nothing to go and check.
The shape is **flat** — the same one
[`GET /contacts/{id}`](/api-reference/contacts/get-contact) returns and
[`PATCH /contacts/{id}`](/api-reference/contacts/update-contact) accepts. There is no
seeded-versus-your-own split in the payload because there is none to make: every key this
accepts is a catalogued key with `writable: true`, whether InstaView seeded it or you defined
it. Read [`GET /contact-fields`](/api-reference/contact-fields/list-contact-fields) for the keys
your company has.
**A rejected value takes the contact with it.** Values are validated against the catalog in
the same transaction as the insert, so an unknown key or a wrong-typed value answers `422`
naming it and the contact is **not** created. That is deliberate: a contact that exists
without the values you sent is a half-write you cannot detect without re-reading.
An unknown key is a `422` by default. Pass
[`?createMissingFields=true`](/api-reference/contacts/update-contact#createmissingfields-true)
to have it **defined for you** as a `string` field instead — convenient for a first sync, and
opt-in per request because a typo would otherwise mint a permanent field in your catalog.
## `fields` Is Not `metadata`
They are stored separately and read separately. `fields` is the validated door: every value is
checked against its field's declared type, enum and reserved-key rules, and only values that
arrive through it are addressable as `{{contact.}}`. `metadata` is your own scratch space,
free-form and never read by an agent, and nothing you put in it becomes a contact field however
you name the key.
## Response Format
The response carries both `jobId` (singular) and `jobIds` (array):
```javascript theme={null}
{
"id": "contact-uuid",
"jobId": "job-uuid", // First job (for backward compatibility)
"jobIds": ["job-uuid"], // Array of all job associations
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com",
"phoneNumber": "+1234567890",
"gdprExpiryDate": "2026-11-16",
"gender": "female",
"status": "APPLIED",
"fields": { // Catalogued values, as stored
"is_vip": true,
"order_number": "SO-40128"
},
"workHistory": [
{
"id": "work-history-uuid",
"companyName": "Tech Corp",
"candidatePosition": "Software Engineer",
"referenceName": "Jane Smith",
"referencePhone": "+1234567890",
"startDate": "2020-01-01",
"endDate": "2022-12-31"
}
],
"createdAt": "2025-11-28T10:30:00Z",
"updatedAt": "2025-11-28T10:30:00Z"
}
```
## Related Resources
Learn about managing contacts and their workflows
Understand job management
Start a conversation with a contact
List and manage your contacts
# Delete Contact
Source: https://docs.instaview.sk/api-reference/contacts/delete-contact
DELETE /contacts/{id}
Permanently deletes a contact that belongs to the API key's company, whether or not it holds job assignments. The delete cascades: every conversation the contact had, with its transcripts, recordings and analyses, is deleted too. This cannot be undone.
`DELETE /candidates/{id}` is a permanent alias of this endpoint and keeps working unchanged, with the same scopes and the same response. See [Resource names](/api-reference/introduction#resource-names).
Permanently deletes a contact. **This action cannot be undone, and it takes the contact's conversations with it.**
## Overview
This endpoint removes a contact and their personal data from your account outright. It is a hard delete, not a flag.
## Use Cases
* **Remove duplicates**: clean up a contact entered twice
* **GDPR compliance**: satisfy an erasure request
* **Cleanup**: drop contacts you no longer hold a reason to keep
## What Else Is Deleted
**Deleting a contact deletes every conversation they had.** Their transcripts, recordings and
analyses go with them, along with their job assignments. Nothing of theirs is readable through
the API afterwards: a later `GET /conversations?contactId=…` for that contact returns nothing,
and there is no endpoint that brings any of it back.
If you need the call history, export it before you issue the delete.
Deleted rows are archived internally first — a deletion audit log and an archive table that
exist for our own compliance obligations. Neither is reachable through the public API, so
neither is a way for you to get the data back.
## GDPR Considerations
This is the deletion the API offers, and it is the one to reach for on an erasure request. Note
the internal archive above before you promise a data subject that nothing remains anywhere: if
your obligation is erasure from our systems rather than from your integration, raise it with
our support team rather than assuming this call has satisfied it.
## Company Isolation
You can only delete contacts belonging to your API key's company. Ownership is validated first.
## Error Scenarios
* **404 Not Found**: the contact does not exist, or has already been deleted
* **403 Forbidden**: the contact belongs to a different company
## Related Resources
Learn about managing contacts
Browse your contacts
Create a new contact
# Complete Contact Document Upload
Source: https://docs.instaview.sk/api-reference/contacts/documents/complete-upload
POST /contacts/{id}/documents/{documentId}/complete
Confirms the file was uploaded to the presigned URL. The stored object is verified — it must exist, be within the size limit, be no larger than the size declared when the upload was started, and carry the content type the URL was issued for — and only then does the document become usable on calls. A file that fails verification is deleted and its reservation released. Safe to retry: completing an already-completed document returns it unchanged.
Confirms that the file was uploaded to the presigned URL and makes the document usable on calls.
## Overview
The stored object is verified before anything changes: it must exist, be within the 20 MiB limit, be **no larger than the size you declared** when starting the upload, and carry the content type the URL was issued for. A file that fails any of these is deleted, its reservation is released, and you get a `400` saying which check failed.
Safe to retry. Completing an already-completed document returns it unchanged with a `200`.
## Example
```bash theme={null}
curl -X POST https://api.instaview.sk/contacts/$CONTACT_ID/documents/$DOCUMENT_ID/complete \
-H "Authorization: Bearer $INSTAVIEW_API_KEY"
```
```json theme={null}
{
"id": "5d3c2b1a-0f9e-4d8c-b7a6-5f4e3d2c1b0a",
"title": "Cover letter",
"fileName": "cover-letter.pdf",
"mimeType": "application/pdf",
"sizeBytes": 482133,
"status": "READY",
"isActive": true,
"source": "UPLOAD",
"createdAt": "2026-09-05T09:30:00.000Z"
}
```
## Error Scenarios
* **400 Bad Request**: Nothing has been uploaded to the URL yet, or the stored file is too large, larger than declared, or of a different content type. A file that fails verification is deleted
* **404 Not Found**: No such document on that contact
* **429 Too Many Requests**: Completion rate limit exceeded — wait for `Retry-After` seconds
## Related Resources
* [Start Contact Document Upload](/api-reference/contacts/documents/start-upload)
* [List Contact Documents](/api-reference/contacts/documents/list-documents)
# Delete Contact Document
Source: https://docs.instaview.sk/api-reference/contacts/documents/delete-document
DELETE /contacts/{id}/documents/{documentId}
Permanently deletes an uploaded document, the stored file and the copy held by the voice provider. This cannot be undone.
The `CV` document cannot be deleted here: it is produced from the contact's processed CV, and would be listed again on the next read. Deactivate it instead, or delete the contact's CV. A contact's documents are also deleted, storage and provider copies included, when the contact reaches its GDPR retention date.
Permanently deletes an uploaded contact document, its stored file and the copy held by the voice provider.
## Overview
Works on `UPLOAD` documents, completed or not — deleting a `PENDING` upload releases its slot and its bytes immediately instead of waiting for the 24-hour cleanup.
The `CV` document is refused with a `409`. It is produced from the contact's processed CV, so deleting the document would only have it listed again on the next read. To keep the CV out of calls, [deactivate it](/api-reference/contacts/documents/update-document); to remove it entirely, delete the contact's CV.
Contact documents follow the contact's retention. When a contact reaches its GDPR expiry date, every
document on it — the `CV` projection and the uploads, their stored files and their provider copies —
is deleted with the rest of the contact's data. There is no second retention clock to manage.
## Example
```bash theme={null}
curl -X DELETE https://api.instaview.sk/contacts/$CONTACT_ID/documents/$DOCUMENT_ID \
-H "Authorization: Bearer $INSTAVIEW_API_KEY"
```
```json theme={null}
{
"id": "5d3c2b1a-0f9e-4d8c-b7a6-5f4e3d2c1b0a",
"deleted": true
}
```
## Error Scenarios
* **404 Not Found**: No such document on that contact
* **409 Conflict**: The document is the contact's `CV`
## Related Resources
* [List Contact Documents](/api-reference/contacts/documents/list-documents)
* [Update Contact Document](/api-reference/contacts/documents/update-document)
# List Contact Documents
Source: https://docs.instaview.sk/api-reference/contacts/documents/list-documents
GET /contacts/{id}/documents
Lists every document on the contact: the processed CV (`source: CV`), completed uploads, and uploads that have not been completed yet (`status: PENDING`).
Whether any of these documents reaches a call is a separate decision: the agent must have `contextConfig.useContactContext` enabled.
Lists every document on a contact: the processed CV, completed uploads, and uploads that have not been completed yet.
## Overview
A contact's documents are what an agent can look up about the specific person it is calling — their CV, a cover letter, application notes. They live in the same knowledge base machinery as an [agent's documents](/api-reference/agents/knowledge/list-knowledge), and the response has the same shape, with one addition: `source`.
| `source` | Meaning |
| - | - |
| `UPLOAD` | A file attached through this API or the dashboard. Can be renamed, deactivated and deleted. |
| `CV` | Produced by InstaView from the contact's processed CV. Appears once the CV has been processed; can be deactivated, but not renamed or deleted here — delete the CV instead. |
`limit` is the per-contact cap on **uploaded** documents (5). The `CV` document does not count towards it.
## Whether a document reaches a call
Listing a document does not mean an agent will see it. One switch decides: **the agent has `contextConfig.useContactContext` enabled** — the agent author's intent to draw on what the platform knows about the person it is calling.
With it on, the contact's active documents are attached to the call as the `candidate` knowledge base, and the agent's prompt gains one sentence telling it the documents exist and may be looked up — never read aloud. With it off, the call runs exactly as it did before, documents or not.
## Example
```bash theme={null}
curl https://api.instaview.sk/contacts/$CONTACT_ID/documents \
-H "Authorization: Bearer $INSTAVIEW_API_KEY"
```
```json theme={null}
{
"data": [
{
"id": "8b2e1f40-3c4d-4e5f-9a6b-7c8d9e0f1a2b",
"title": "CV",
"fileName": "jana-novakova-cv.pdf",
"mimeType": "text/markdown",
"status": "READY",
"isActive": true,
"source": "CV",
"createdAt": "2026-09-05T08:12:00.000Z"
},
{
"id": "5d3c2b1a-0f9e-4d8c-b7a6-5f4e3d2c1b0a",
"title": "Cover letter",
"fileName": "cover-letter.pdf",
"mimeType": "application/pdf",
"sizeBytes": 482133,
"status": "READY",
"isActive": true,
"source": "UPLOAD",
"createdAt": "2026-09-05T09:30:00.000Z"
}
],
"total": 2,
"limit": 5
}
```
The `CV` document's `fileName` is the name of the file that was uploaded for processing; what the
agent can look up is the **anonymized text** of that CV — names, contact details and employer
names replaced with generic terms — not the original file, which InstaView does not keep.
## Error Scenarios
* **404 Not Found**: No contact with that id belongs to your API key's company
## Related Resources
* [Start Contact Document Upload](/api-reference/contacts/documents/start-upload)
* [Update Contact Document](/api-reference/contacts/documents/update-document)
* [Contacts Guide](/guides/resources/contacts#documents)
# Start Contact Document Upload
Source: https://docs.instaview.sk/api-reference/contacts/documents/start-upload
POST /contacts/{id}/documents/uploads
Reserves a document slot on the contact — and the declared bytes against your company's contact-document storage — and returns a presigned URL to upload the file to. The file never passes through this API.
1. `POST` this endpoint with the file's name, MIME type and size.
2. `PUT` the raw file bytes to the returned `uploadUrl`, sending `Content-Type` exactly as returned in `contentType`. Do **not** send your API key with this request — the URL carries its own authorisation.
3. `POST /contacts/{id}/documents/{documentId}/complete` to confirm the upload.
The document stays `PENDING`, and is never used on a call, until step 3 succeeds. Uploads that are never completed are cleaned up automatically after 24 hours and release both the slot and the bytes they reserved.
A contact holds at most 5 uploaded documents. The company's total contact-document storage is capped by its plan; a request that would exceed it is refused with a `400` naming the bytes in use, the ceiling and the size requested.
This endpoint has a lower rate limit than the rest of the API.
Reserves a document slot on a contact and returns a presigned URL to upload the file to.
## Overview
The same three-step flow as an [agent knowledge upload](/api-reference/agents/knowledge/start-upload), because the file bytes go straight to storage and never pass through this API.
`POST /contacts/{id}/documents/uploads` with the file's name, MIME type and size. You get back a `documentId` and an `uploadUrl`.
`sizeBytes` must be the file's **exact** byte count. It is signed into the upload URL, and it is
also what is reserved against your company's contact-document storage — so a larger file is
refused at completion, not quietly admitted.
`PUT` the raw file bytes to `uploadUrl`, sending `Content-Type` exactly as returned in `contentType`.
Do **not** send your API key with this request. The upload URL carries its own authorisation.
`POST /contacts/{id}/documents/{documentId}/complete`. The stored file is verified, and only then does the document become usable on calls.
## Example
```bash theme={null}
# 1. Reserve a slot
curl -X POST https://api.instaview.sk/contacts/$CONTACT_ID/documents/uploads \
-H "Authorization: Bearer $INSTAVIEW_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"fileName": "cover-letter.pdf",
"mimeType": "application/pdf",
"sizeBytes": 482133,
"title": "Cover letter"
}'
# 2. Upload the bytes (no API key on this request)
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/pdf" \
--data-binary @cover-letter.pdf
# 3. Confirm
curl -X POST https://api.instaview.sk/contacts/$CONTACT_ID/documents/$DOCUMENT_ID/complete \
-H "Authorization: Bearer $INSTAVIEW_API_KEY"
```
## Supported File Types
PDF, DOC, DOCX, TXT, MD, CSV, TSV, JSON, XML and YAML — the same set as agent knowledge.
## Limits
| Limit | Value |
| - | - |
| Maximum file size | 20 MiB (20,971,520 bytes) |
| Uploaded documents per contact | 5 (the `CV` document does not count) |
| Contact-document storage per company | Plan-tiered; the `400` names the figures when exceeded |
| Upload URL validity | 15 minutes |
| Unfinished uploads reclaimed after | 24 hours |
Both the slot and the bytes are **reserved** the moment this call succeeds, pending uploads included.
That is what stops several parallel requests from each passing the same check. An abandoned upload
releases both automatically after 24 hours — or immediately, if you `DELETE` the document.
The storage ceiling is per company and per plan. A contact is not a document library, and the aggregate is the number that actually costs money — every byte here is stored and, once the contact is on a call, sent to the voice provider. When a reservation would take the company past its ceiling, the response is a `400` of the form:
```text theme={null}
Contact document storage limit reached for this company: 248MB of 250MB in use, and this file needs 3MB more.
Delete contact documents or upgrade the plan.
```
## Rate Limiting
Shares the agent knowledge upload limit: **6 requests per minute and 60 per hour** per API key for this endpoint, with completion limited separately and more generously. A `429` carries a `Retry-After` header.
## Error Scenarios
* **400 Bad Request**: Unsupported file type, a size over 20 MB, the contact already holds 5 documents, or the company is at its storage limit
* **404 Not Found**: No contact with that id belongs to your API key's company
* **429 Too Many Requests**: Upload rate limit exceeded — wait for `Retry-After` seconds
## Related Resources
* [Complete Contact Document Upload](/api-reference/contacts/documents/complete-upload)
* [List Contact Documents](/api-reference/contacts/documents/list-documents)
# Update Contact Document
Source: https://docs.instaview.sk/api-reference/contacts/documents/update-document
PATCH /contacts/{id}/documents/{documentId}
Renames a document and/or activates or deactivates it. Deactivating keeps the document but stops it being attached to new calls — it is also the way to keep a contact's CV out of calls. Only completed (`READY`) documents can be toggled, and the `CV` document cannot be renamed. At least one of `title` or `isActive` is required.
Renames a contact document and/or controls whether agents may use it.
## Overview
Send `title`, `isActive`, or both. An empty body is a `400`.
**Deactivating** keeps the document but stops it being attached to new calls. It is also the way to keep a contact's CV out of calls while keeping the CV itself: set `isActive: false` on the document whose `source` is `CV`. Nothing about the contact's processing changes; the agent simply no longer has the document to look up.
The `CV` document cannot be **renamed** — its title is set by the platform and would be reset the next time the CV was synced. Only completed (`READY`) documents can be toggled; a `PENDING` upload is a `409`.
## Example
```bash theme={null}
# Keep this contact's CV out of calls
curl -X PATCH https://api.instaview.sk/contacts/$CONTACT_ID/documents/$CV_DOCUMENT_ID \
-H "Authorization: Bearer $INSTAVIEW_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "isActive": false }'
```
```json theme={null}
{
"id": "8b2e1f40-3c4d-4e5f-9a6b-7c8d9e0f1a2b",
"title": "CV",
"fileName": "jana-novakova-cv.pdf",
"mimeType": "text/markdown",
"status": "READY",
"isActive": false,
"source": "CV",
"createdAt": "2026-09-05T08:12:00.000Z"
}
```
## Error Scenarios
* **400 Bad Request**: Neither `title` nor `isActive` was provided
* **404 Not Found**: No such document on that contact
* **409 Conflict**: The upload has not been completed yet, or the request renames the `CV` document
## Related Resources
* [List Contact Documents](/api-reference/contacts/documents/list-documents)
* [Delete Contact Document](/api-reference/contacts/documents/delete-document)
# Get Contact
Source: https://docs.instaview.sk/api-reference/contacts/get-contact
GET /contacts/{id}
Returns a single contact, ensuring the contact (and any job associations) belong to the API key's company.
`GET /candidates/{id}` is a permanent alias of this endpoint and keeps working unchanged, with the same scopes and the same response. See [Resource names](/api-reference/introduction#resource-names).
Retrieves a single contact by ID, with their details, status and job associations.
## Overview
This endpoint returns everything held about one contact: their name, how to reach them, their status, the jobs they are associated with, and your own metadata. Use it before starting a conversation, or before an update.
## Use Cases
* **Display a profile**: show everything you hold about someone in your own UI
* **Pre-flight check**: confirm the contact exists and is reachable by your key before creating a conversation
* **Status checking**: read where they have got to in your process
* **Reconciliation**: confirm the record matches your other systems
## Response Data
The response includes:
* Name, email and phone number
* Job associations (`jobId` and `jobIds`)
* Current status
* Gender, if set — used for gender-aware addressing on the call
* Metadata (anything of your own: resume URL, LinkedIn, an external id)
* `fields` — the contact's catalogued field values, keyed by field key
* Timestamps (created, updated)
### `fields` vs `metadata`
Stored separately on the same contact, and only one of them reaches an agent.
| | `metadata` | `fields` |
| - | - | - |
| What it is | your own scratch space | values defined in your company catalog |
| Validated | no | yes — against each field's type and enum |
| An agent can say it | **no** | yes, as `{{contact.}}` |
| Written by | `POST` / `PATCH /contacts` | `POST` / `PATCH /contacts`, as [`fields`](/api-reference/contacts/update-contact#updating-field-values) |
A field with no value is absent from `fields` rather than `null`, and `{}` means none are set.
A value whose definition has since been deleted is omitted too — the data survives the delete and
reappears if you define the key again. The catalog itself is
[`GET /contact-fields`](/api-reference/contact-fields/list-contact-fields).
Three fields are declared on the contact schema but **not currently returned** by any endpoint:
`analysisCount`, `interviewCount` and `links`. Nothing populates them, so treat them as absent
rather than as zero — and count conversations with [List
Conversations](/api-reference/conversations/list-conversations) instead. `interviewCount` also
keeps its original name; it was never given a neutral alias.
## Company Isolation
You can only read contacts belonging to your API key's company. Anything else returns `403`.
## Error Scenarios
* **404 Not Found**: the contact does not exist, or has been deleted
* **403 Forbidden**: the contact belongs to a different company
## Related Resources
Learn the contact structure and its status workflow
Change a contact's details
Browse your contacts
# List Contacts
Source: https://docs.instaview.sk/api-reference/contacts/list-contacts
GET /contacts
Lists the contacts that belong to the API key's company, whether or not they hold job assignments.
`GET /candidates` is a permanent alias of this endpoint and keeps working unchanged, with the same scopes and the same response. See [Resource names](/api-reference/introduction#resource-names).
Lists your contacts, with pagination and filtering.
## Overview
This endpoint returns the contacts belonging to your API key's company. You can filter by job or status, search by name or email, and page through the results — useful for finding someone before starting a conversation with them.
## Use Cases
* **Browse your contacts**: review everyone your company holds
* **Job-specific filtering**: narrow to the contacts assigned to one job
* **Status tracking**: filter by where someone has got to in your process
* **Search**: locate a contact by name or email
## Basic Usage
```javascript theme={null}
// List all contacts (first page, 20 items)
GET /contacts
```
## With Filters
```javascript theme={null}
// Filter by job
GET /contacts?jobId=job-uuid
// Filter by status
GET /contacts?status=IN_PROCESS
// Search by name or email
GET /contacts?search=jane@example.com
// Combine filters with pagination
GET /contacts?jobId=job-uuid&status=APPLIED&page=1&limit=50
```
`status` is one of `UNDEFINED`, `APPLIED`, `IN_PROCESS`, `REJECTED` or `ACCEPTED`. The names
are hiring-flavoured because they predate the neutral vocabulary; they are the values the API
accepts, so they are unchanged.
## Company Scoping
Every contact returned belongs to your API key's company. Contacts from other companies are filtered out.
## Related Resources
Learn about managing contacts, status workflows, and filtering
Understand pagination best practices
Retrieve a single contact by ID
# Update Contact
Source: https://docs.instaview.sk/api-reference/contacts/update-contact
PATCH /contacts/{id}
Updates a contact that belongs to the API key's company. Only basic profile, reachability and status fields are supported.
`PATCH /candidates/{id}` is a permanent alias of this endpoint and keeps working unchanged, with the same scopes and the same response. See [Resource names](/api-reference/introduction#resource-names).
Updates an existing contact. Only the fields you send are changed; everything else is left alone.
## Overview
A partial update, so you send the fields you want to change and nothing more. Use it to correct a phone number, move someone's status, change their job assignments, or attach metadata.
## Use Cases
* **Status updates**: move a contact through your own process
* **Reachability**: change email, phone number or name
* **Gender override**: set or clear it for accurate addressing on the call
* **Job assignments**: attach, replace or clear them
* **Metadata**: add or change your own opaque key-value pairs — not the same thing as [contact fields](/api-reference/contacts/update-contact)
* **Refinement**: fill the record in as you learn more
## Partial Updates
You can update any combination of fields:
```javascript theme={null}
// Update only status
PATCH /contacts/{id}
{
"status": "IN_PROCESS"
}
// Update how to reach them
PATCH /contacts/{id}
{
"email": "newemail@example.com",
"phoneNumber": "+1987654321"
}
// Assign to multiple jobs
PATCH /contacts/{id}
{
"jobIds": ["job-uuid-1", "job-uuid-2", "job-uuid-3"]
}
// Remove all job assignments
PATCH /contacts/{id}
{
"jobIds": []
}
// Set gender explicitly
PATCH /contacts/{id}
{
"gender": "female"
}
// Clear gender (revert to auto-detection)
PATCH /contacts/{id}
{
"gender": null
}
// Replace the work history
PATCH /contacts/{id}
{
"workHistory": [
{
"companyName": "Tech Corp",
"candidatePosition": "Senior Software Engineer",
"referenceName": "Jane Smith",
"referencePhone": "+1234567890",
"startDate": "2020-01-01"
}
]
}
```
**`metadata` is not readable by an agent.** It is your own scratch space — free-form,
unvalidated, and never placed in a prompt. If you want a value an agent can *say*, it has to
be a **contact field**: catalogued, typed, and addressable as `{{contact.}}`. See
[Update Contact](/api-reference/contacts/update-contact).
**Sending `metadata` never disturbs the contact's fields.** The two are stored separately, so
replacing `metadata` wholesale cannot reach the catalogued values: they survive the write
untouched, and the response shows them back to you under `fields`. A routine sync of your own
payload cannot erase a value an agent depends on.
It does not work as a back door either. Nothing on the `metadata` path checks a value against
its field's type, enum or reserved-key rules, and nothing reads a contact field out of it —
whatever you put there stays your own scratch space. `fields` below is the validated door, on
this endpoint and on [`POST /contacts`](/api-reference/contacts/create-contact), and it answers
`422` naming the key when a value does not fit.
## Updating Field Values
Send `fields` to change a contact's catalogued values in the same request as anything else:
```javascript theme={null}
PATCH /contacts/{id}
{ "status": "contacted", "fields": { "order_number": "SO-40129" } }
```
**`fields` merges; `metadata` replaces.** A key you omit keeps its value, and an explicit
`null` clears one. The two behave differently in the same request body on purpose:
`metadata` is one opaque document you own, `fields` are individually catalogued values.
```javascript theme={null}
// Change one value, leave every other alone
PATCH /contacts/{id}
{ "fields": { "order_number": "SO-40129" } }
// Clear one
PATCH /contacts/{id}
{ "fields": { "renewal_date": null } }
```
`"fields": null` is not a wipe — it reads as "no values in this request" and is a no-op.
Clearing is per key, which is what stops a partial update from blanking a contact by accident.
### Values are validated against the catalog
Every value is checked against its field's declared type, and an unknown key is refused. Both
answer `422` **naming the key** — never a silent drop, because a caller who believes it wrote a
value it did not is the worse outcome. `422` rather than `400`: the request is well formed, it
is the catalog that rejects its contents.
Validation runs in the same transaction as the contact update, so a rejection applies
**nothing** — not the status, not the metadata, not the other values.
| The field is | Accepted | Refused |
| - | - | - |
| `string` | any text (trimmed) | an object |
| `number` | `42`, `"42"`, `"-7.5"` | `"twelve"`, `"12abc"` |
| `bool` | `true`, `"true"`, `"yes"`, `"1"` and negatives | `"maybe"` |
| `date` | `"2026-04-20"`, any parseable date | `"not a date"` |
| `enum` | one of the field's `enumOptions` | anything else — the error lists the options |
| `tags` | `["vip","renewal"]`, **or** `"vip, renewal"` | `42`, `true`, an object |
Reserved and read-only keys are refused too: `first_name`, `email` and the rest are set as the
contact's own properties above, and `company_description` and `agent_name` resolve from your
company and your agent, so a value stored under them would be shadowed on every call.
### `tags` takes two shapes
A `tags` field stores a `string[]` and accepts either the array or **one comma-separated
string** — so a single CSV cell holding several tags maps onto one field without your side
splitting it first. The two are equivalent:
```javascript theme={null}
PATCH /contacts/{id}
{ "fields": { "tags": ["vip", "renewal"] } }
// Same result
PATCH /contacts/{id}
{ "fields": { "tags": "vip, renewal" } }
```
Entries are trimmed, blanks are dropped (`"vip,, renewal,"` is two tags) and duplicates are
removed case-insensitively, keeping the spelling that came first — `["VIP","vip"]` stores
`["VIP"]`.
**A comma is always a separator, so a tag cannot contain one.** That holds inside an array
element too: `["north, east"]` stores two tags, exactly as `"north, east"` does. The two
shapes would otherwise mean different things, and a value edited in the dashboard — which
renders the list as one comma-joined line — would split on the next save with no warning.
Bounded, because these are stored on the contact and read on every call: at most **50 tags**,
**60 characters** per tag, and **2000 characters** of raw tag text per request. Each is a `422`
naming the limit.
**An empty list is the erase.** `[]`, `[""]` and `","` all say the contact has no tags, so
they clear the field exactly as `null` and `""` do — you never get a key present with an
empty array behind it.
What it will not do is guess: a number, a boolean or an object is a `422` rather than a
one-element list, because a caller sending `42` for a tags field has a mapping bug and a
silent `["42"]` would be spoken on a call.
### `?createMissingFields=true`
Off by default. When passed, a key in `fields` that your catalog does not know yet is
**defined for you** as a `string` field, instead of the request being rejected.
```javascript theme={null}
PATCH /contacts/{id}?createMissingFields=true
{ "fields": { "warranty_ref": "W-9921" } }
// warranty_ref now exists in your catalog, labelled "Warranty ref"
```
It works the same way on [`POST /contacts`](/api-reference/contacts/create-contact).
**A typo becomes a permanent field.** `oder_number` creates `oder_number`, visible in every
agent's variable palette until somebody deletes it. That is why the option is opt-in per
request rather than a mode you can leave on.
Two things to know:
* **It needs no extra scope.** The same `write:contacts` that lets you define a field
explicitly lets you define one implicitly. What it will still never do is quietly ignore an
unknown key you did not ask it to create.
* **The field is always `string`.** The value is the only evidence available and it is a poor
one: `"1"` could be a number, a boolean or an order reference, and a wrong guess becomes
validation that rejects your next row.
[`PATCH /contact-fields/{id}`](/api-reference/contact-fields/update-contact-field) retypes it
afterwards. Reserved keys, malformed keys and a company already at its field ceiling are all
still refused.
For anything you control, define the fields explicitly with
[`POST /contact-fields`](/api-reference/contact-fields/create-contact-field) — you get the right
type and label, and a typo fails loudly.
## Managing Job Assignments
`jobIds` sets which jobs a contact is associated with. It **replaces** the array rather than adding to it:
```javascript theme={null}
// Assign the contact to several jobs
await updateContact("contact-uuid", {
jobIds: ["job-1-uuid", "job-2-uuid", "job-3-uuid"],
});
// Add one to the existing assignments
// Note: fetch current state first, since PATCH replaces the whole jobIds array
const contact = await getContact("contact-uuid");
await updateContact("contact-uuid", {
jobIds: [...contact.jobIds, "new-job-uuid"],
});
// Replace every assignment with one job
await updateContact("contact-uuid", {
jobIds: ["only-this-job-uuid"],
});
// Remove every job assignment
await updateContact("contact-uuid", {
jobIds: [],
});
```
**Job validation**: every id in `jobIds` must name an existing job belonging to your API key's
company. An unknown id is a validation error, not a silent omission.
**Concurrent `jobIds` updates lose assignments.** Because the field replaces the array, adding
one job is a read-modify-write, and the endpoint offers no precondition to make that safe — no
`If-Match`, no version field. Two requests that both read `["job-1"]` and each append their own
job leave the contact with whichever wrote last, and the other assignment is gone. It is not
reported: both calls answer `200`.
Serialise `jobIds` writes for a given contact — a queue or a lock of your own, keyed on the
contact id — rather than issuing them in parallel from several workers. Fields that are not
read-modify-write (`email`, `status`, `metadata`) do not have this problem.
## Status Values
`status` is one of:
| Value | Meaning |
| - | - |
| `UNDEFINED` | No status set |
| `APPLIED` | The starting point for an inbound contact |
| `IN_PROCESS` | Somewhere in your process |
| `REJECTED` | Closed out unsuccessfully |
| `ACCEPTED` | Closed out successfully |
The names are hiring-flavoured because they predate the neutral vocabulary, and they are the
values the API accepts, so they are unchanged. Sending anything else is a `400`.
## Company Isolation
You can only update contacts belonging to your API key's company. Ownership is validated before anything is written.
## Error Scenarios
* **404 Not Found**: the contact does not exist, or has been deleted
* **403 Forbidden**: the contact belongs to a different company, or a `jobIds` entry names another company's job
* **400 Bad Request**: invalid field values, or a `jobIds` entry naming no job at all
## Related Resources
Learn about managing contacts and their status workflow
Read the current state first
Create a new contact
# Create Conversation
Source: https://docs.instaview.sk/api-reference/conversations/create-conversation
POST /conversations
Creates a conversation with a contact, using an existing or inline contact/agent/job, scoped to the API key's company. Respects company billing limits and subscription plans. Set isTest=true to create a test conversation that completes immediately without consuming billing minutes.
`POST /interviews` is a permanent alias of this endpoint and keeps working unchanged, with the same scopes and the same response. See [Resource names](/api-reference/introduction#resource-names).
Creates a conversation — one AI-run call to one contact, by phone or on the web. You can point at contacts and agents you already have, or define them inline in the same request.
## Overview
A conversation is created either to run immediately or at a `scheduleTime` you name. Everything it needs can come from ids you already hold (`contactId`, `agentId`) or from inline objects the request creates as it goes, so an integration that has just learned about a person does not need two round trips before it can call them.
## Use Cases
* **Scheduled calls**: Run the conversation at a specific date and time
* **Immediate calls**: Start as soon as the agent's calling hours allow
* **Inline resources**: Create a conversation without pre-creating the contact or the agent
* **Bulk outreach**: Automate one conversation per contact across a list
## Inline Contact, Agent, and Job Support
Contacts, agents and jobs that do not exist yet can be defined inline as objects on the request. Nothing has to be created first.
The inline contact body is `contact`. `candidate` is its permanent legacy alias and takes the
same fields; send one or the other, not both. The same holds for `contactId` and `candidateId`.
### Inline Contact with Job Association
```javascript theme={null}
{
"contact": {
"jobId": "job-uuid",
"firstName": "Jane",
"lastName": "Doe",
"email": "jane@example.com",
"phoneNumber": "+1234567890",
"gdprExpiryDate": "2026-11-16",
"workHistory": [
{
"companyName": "Tech Corp",
"candidatePosition": "Software Engineer",
"referenceName": "Jane Smith",
"referencePhone": "+1234567890",
"startDate": "2020-01-01",
"endDate": "2022-12-31"
}
]
},
"agentId": "existing-agent-uuid",
"scheduleTime": new Date(Date.now() + 60 * 60 * 1000).toISOString()
}
```
### Inline Contact with Inline Job
Create both the contact and the job inline for a completely self-contained request:
```javascript theme={null}
{
"contact": {
"firstName": "Jane",
"lastName": "Doe",
"email": "jane@example.com",
"phoneNumber": "+1234567890",
"gdprExpiryDate": "2026-11-16"
},
"job": {
"jobTitle": "Senior Backend Engineer",
"jobDescription": "We are looking for a senior engineer...",
"requiredSkills": ["TypeScript", "NestJS", "PostgreSQL"],
"niceToHaveSkills": ["Redis", "AWS"],
"languages": ["EN"],
"experience": "SENIOR",
"contractType": "FULL_TIME"
},
"agentId": "existing-agent-uuid",
"scheduleTime": new Date(Date.now() + 60 * 60 * 1000).toISOString()
}
```
**Complete inline workflow**: when you send `contact` together with `job`: - a new **Job** is
created for your company - a new **Contact** is created and automatically associated with that
job - the **Conversation** is linked to both - all three become permanent resources you can
fetch and manage afterwards
### Inline Contact without Job Association
```javascript theme={null}
{
"contact": {
"firstName": "Jane",
"lastName": "Doe",
"email": "jane@example.com",
"phoneNumber": "+1234567890",
"gdprExpiryDate": "2026-11-16"
},
"agentId": "existing-agent-uuid",
"scheduleTime": new Date(Date.now() + 60 * 60 * 1000).toISOString()
}
```
**Job specification options**: you can name the job in several ways: - **Create inline**: use
`job` at the top level to create a new job - **Reference existing**: use `jobId` at the top
level to link an existing job - **Within the inline contact**: use `jobId` inside `contact`
for a more compact request when creating the contact and the association together - **No
job**: omit every job field. A job is optional on a conversation, and most callers outside
hiring never send one; the contact can still be assigned to jobs later via the [update
contact endpoint](/api-reference/contacts/update-contact). **Priority**: when several job
sources are provided, `job` wins over top-level `jobId`, which wins over `contact.jobId`.
**XOR validation**: `contact.jobId` cannot be combined with a top-level `jobId` or `job`,
since that would be ambiguous. Pick one per request.
### Inline Custom Agent
The inline `agent` accepts the same custom-agent fields as [Create Agent](/api-reference/agents/create-agent#custom-agents): send a `flow` (optionally with `guardrails`, `contextConfig` and `analyticsConfig`) and the agent is created as a custom agent, running the conversation you designed instead of a template. As there, `focus` must be omitted when a `flow` is present, ids and `schemaVersion` are assigned by InstaView, and an invalid flow is rejected with a `422` carrying [the same `errors` array](/api-reference/agents/create-agent#flow-validation-errors) — every problem found, each with the `path` of the block it is on. No conversation is created when that happens.
Sending `analyticsConfig` here is how a one-off call returns structured data rather than just a transcript — the results come back as [`analytics`](/api-reference/conversations/get-conversation#custom-agent-analytics) on this conversation and on its `analysis.completed` webhook.
```javascript theme={null}
{
"contact": {
"firstName": "Jana",
"lastName": "Novák",
"phoneNumber": "+421900123456",
"gdprExpiryDate": "2027-08-02"
},
"agent": {
"name": "Renewal check-in",
"type": "PHONE",
"language": "EN",
"duration": 10,
"companyPhoneNumberId": "123e4567-e89b-12d3-a456-426614174000",
"flow": {
"firstMessage": { "type": "first_message", "text": "Hi {{contact.first_name}}, quick question about your renewal." },
"sections": [{ "type": "sequential", "prompt": "Ask whether they intend to renew, and note any blockers." }],
"lastMessage": { "type": "last_message", "text": "Thanks — we'll follow up by email." }
},
"analyticsConfig": {
"extractionTargets": [
{ "key": "intends_to_renew", "label": "Intends to renew", "type": "bool" }
],
"outcomes": [
{ "label": "Renewal confirmed", "description": "The contact committed to renewing" }
]
}
}
}
```
**Inline creation mints a permanent agent per conversation.** That is fine for a one-off,
but calling a list of 50 people this way leaves 50 near-identical custom agents behind.
When the same flow will call more than one person, create the agent once via
[POST /agents](/api-reference/agents/create-agent) and pass `agentId`.
## Job-Optional Focuses
Two kinds of agent are **job-optional** — they can call someone without associating the
conversation with a job: `GENERIC`, and any custom agent (one created with a `flow`). Every other
focus requires a job. The two are not identical, and the difference is worth knowing:
| | `GENERIC` | Custom (flow-based) |
| - | - | - |
| Conversation without a job | Yes, always | Yes, always |
| Contact may hold job assignments | Yes, any number | Yes, any number |
| `jobId` needed for a multi-job contact | No | No |
| May you attach a job anyway | Yes, optional | **No — `400`** |
**A job-optional focus ignores the contact's assignments entirely.** You do not need to
pick one with `jobId`, and a contact assigned to five jobs is called exactly like one
assigned to none. The "specify a `jobId`" error described under [Contact with Multiple
Jobs](#contact-with-multiple-jobs) applies only to job-requiring focuses.
**Custom agents are always jobless.** An agent created with a `flow` is job-agnostic by
design: its prompt comes entirely from the flow, so a job would never be read. This is
stricter than `GENERIC`, which merely makes a job optional — sending `job`, `jobId` or
`contact.jobId` for a custom agent is a `400` rather than a silently ignored field.
`GENERIC` agents exist for conversations that are not about a job at all. That covers most
non-hiring work, and in hiring it covers:
* **Talent pool building**: calling people about future openings rather than a specific role
* **General outreach**: reaching contacts in your database who hold no job assignment
* **Pre-screening**: an initial conversation before anyone is matched to a position
### Creating a Jobless Conversation with GENERIC Focus
```javascript theme={null}
{
"contact": {
"firstName": "Jane",
"lastName": "Doe",
"email": "jane@example.com",
"phoneNumber": "+1234567890",
"gdprExpiryDate": "2026-11-16"
// No jobId - a GENERIC conversation does not need one
},
"agentId": "generic-agent-uuid", // Agent with focus: "GENERIC"
"scheduleTime": new Date(Date.now() + 60 * 60 * 1000).toISOString()
}
```
**Validation**: The API will return a `400 Bad Request` error if:
* You try to create a jobless conversation (no job specified) with an agent whose focus
requires a job — that is, anything other than `GENERIC` or a custom agent
* You attach a job to a custom agent's conversation
Note that a `GENERIC` conversation is **not** rejected for a contact who has job assignments:
the assignments are simply not used.
For more detail on how a `GENERIC` conversation is analysed, see the [Conversations Resource Guide](/guides/resources/conversations#conversation-types--analysis).
### Existing Contact with Inline Job
You can also define the job inline using `job` when you already have the contact but don't want to create a separate job resource first.
```javascript theme={null}
{
"contactId": "contact-uuid",
"job": {
"jobTitle": "Senior Backend Engineer",
"jobDescription": "We are looking for a senior engineer...",
"jobUrl": "https://company.com/jobs/123",
"benefits": "Health insurance, remote work",
"requiredSkills": ["TypeScript", "NestJS", "PostgreSQL"],
"niceToHaveSkills": ["Redis", "AWS"],
"languages": ["EN"],
"languageRequirements": [
{ "language": "EN", "proficiency": "C1" }
],
"education": ["BACHELORS"],
"experience": "SENIOR",
"otherRequirements": "Comfortable with async communication",
"contractType": "FULL_TIME",
"location": {
"workMode": "REMOTE",
"street": "Main Street 1",
"city": "Bratislava",
"postalCode": "81101",
"countryCode": "SK"
},
"salary": {
"min": 4000,
"max": 5500,
"currency": "EUR",
"period": "MONTHLY"
},
"humanRecruiter": "John Doe",
"metadata": {
"externalJobId": "JOB-12345",
"department": "Engineering"
}
},
"agentId": "existing-agent-uuid",
"scheduleTime": new Date(Date.now() + 60 * 60 * 1000).toISOString()
}
```
**Inline job behaviour**: when you provide `job` together with an existing `contactId`, the
API: - creates a full **Job** for your company from the inline fields - associates the contact
with that job if they were not already linked - links the conversation to the new job.
`jobId` and `job` are **mutually exclusive (XOR)**: use either `jobId` (an existing job) or
`job` (an inline definition), never both.
## Billing and Limits
Creating a conversation consumes call minutes from your company's billing plan. Make sure
enough are available before scheduling in bulk — the API returns `402 Payment Required` once
billing limits are exceeded, with a `billing` object naming what the request needed and what
was left. See [Billing errors](/guides/error-handling#billing-errors).
**Exception:** test conversations (created with `isTest: true`) consume no minutes and bypass all billing checks.
## Scheduling Options
### Immediate Conversation
Omit `scheduleTime` to start the conversation as soon as possible:
```javascript theme={null}
{
"contactId": "contact-uuid",
"agentId": "agent-uuid"
}
```
With no `scheduleTime`, the time is ours to pick, so the agent's [calling window](/api-reference/agents/create-agent#retries-and-calling-hours) applies: a conversation created outside those hours goes out when they next open, in the contact's own timezone.
### Scheduled Conversation
Provide a future `scheduleTime` timestamp (max 30 days in the future):
```javascript theme={null}
{
"contactId": "contact-uuid",
"agentId": "agent-uuid",
"scheduleTime": new Date(Date.now() + 60 * 60 * 1000).toISOString()
}
```
**A `scheduleTime` you send is used exactly as given**, including when it falls outside the
agent's calling window. You know things the window does not, such as an appointment the contact
agreed to, so naming a time overrides the hours rather than being moved into them. The window
still governs everything we schedule ourselves: conversations created without a `scheduleTime`,
and every retry after a call that did not connect.
The future and 30-day limits above still apply, and are the only bounds on the value. Within
them the offset only pins the moment: `14:00:00Z` and `16:00:00+02:00` are the same instant and
are treated identically.
**Note**: a `contactId` must already exist. For an inline `contact`, every required field
(`firstName`, `lastName`, `email` or `phoneNumber`, `gdprExpiryDate`) must be present.
## Job Selection for Existing Contacts
When using an existing `contactId`, you can optionally name a `jobId` to record which job the conversation is for. This matters when the contact is associated with several jobs.
### Contact with Single Job
If the contact has one job or none, `jobId` is optional — the contact's first job is used if there is one:
```javascript theme={null}
{
"contactId": "contact-uuid",
"agentId": "agent-uuid",
"scheduleTime": new Date(Date.now() + 60 * 60 * 1000).toISOString()
// jobId is optional - the contact's first job is used
}
```
### Contact with Multiple Jobs
When the contact is associated with several jobs, you **must** name the `jobId`:
```javascript theme={null}
{
"contactId": "contact-uuid",
"jobId": "job-uuid", // Required when the contact has multiple job assignments
"agentId": "agent-uuid",
"scheduleTime": new Date(Date.now() + 60 * 60 * 1000).toISOString()
}
```
**Multiple job assignments**: if the contact holds several and you send no `jobId`, the API
returns `400 Bad Request` with the message *"This candidate is assigned to multiple jobs.
Please specify a 'jobId' to associate with this interview."* — quoted as it is actually sent.
Error strings are matched on by integrations, so they keep their original wording; only the
resource names in this documentation changed.
This applies only to job-requiring focuses. `GENERIC` and custom (flow-based) agents are
job-optional, so they call a multi-job contact without a `jobId` — and a custom agent
rejects one outright.
**Job association**: the `jobId` you provide must belong to the same company as your API key. If the contact is not already assigned to that job, they are assigned to it when the conversation is created.
**Job selection rules**: `jobId` and `job` are XOR (mutually exclusive) — provide `jobId` and
omit `job`, or provide `job` and omit `jobId`. When `job` is used: a new job is created for
your company, that job becomes the conversation's `jobId`, and the contact is associated with
it before the conversation is created.
## Company Isolation
Every resource involved (contact, agent, job) must belong to the same company as your API key. The API validates ownership and returns `403 Forbidden` when they do not.
## Complete Workflow Examples
### Scenario 1: Conversation with Job Association
```javascript theme={null}
// 1. Create a job (if not exists)
const job = await createJob({
title: "Senior Software Engineer",
status: "OPEN",
});
// 2. Create the conversation with an inline contact linked to that job
const conversation = await createConversation({
contact: {
jobId: job.id,
firstName: "Jane",
lastName: "Doe",
email: "jane@example.com",
phoneNumber: "+1234567890",
gdprExpiryDate: "2026-11-16",
},
agentId: "agent-uuid",
scheduleTime: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // Tomorrow
});
// 3. Monitor its status
const status = await getConversation(conversation.id);
console.log(`Conversation status: ${status.status}`);
```
### Scenario 2: Conversation without Job Association
```javascript theme={null}
// 1. Create a conversation for a contact who holds no job assignment
const conversation = await createConversation({
contact: {
firstName: "John",
lastName: "Smith",
email: "john@example.com",
phoneNumber: "+1234567890",
gdprExpiryDate: "2026-11-16",
},
agentId: "agent-uuid",
scheduleTime: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
});
// 2. Later, assign the contact to jobs if you need to
const contactId = conversation.contactId;
await updateContact(contactId, {
jobIds: ["job-uuid-1", "job-uuid-2"],
});
```
### Scenario 3: Conversation for a Contact with Multiple Jobs
```javascript theme={null}
// 1. The contact is associated with several jobs
const contact = await getContact("contact-uuid");
// contact.jobIds = ["job-1-uuid", "job-2-uuid", "job-3-uuid"]
// 2. Create the conversation for one of them (jobId is required here)
const conversation = await createConversation({
contactId: "contact-uuid",
jobId: "job-2-uuid", // Must specify which job
agentId: "agent-uuid",
scheduleTime: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
});
// 3. The conversation is now associated with job-2-uuid
console.log(`Conversation created for job: ${conversation.jobId}`);
```
### Scenario 4: Existing Contact with Inline Job
```javascript theme={null}
// 1. The contact already exists in your system
const contactId = "contact-uuid";
// 2. Create the conversation and define the job inline
const conversation = await createConversation({
contactId,
job: {
jobTitle: "Senior Backend Engineer",
jobDescription: "We are looking for a senior engineer...",
requiredSkills: ["TypeScript", "NestJS", "PostgreSQL"],
niceToHaveSkills: ["Redis", "AWS"],
languages: ["EN"],
experience: "SENIOR",
contractType: "FULL_TIME",
location: { workMode: "REMOTE", countryCode: "SK" },
metadata: { externalJobId: "JOB-12345" },
},
agentId: "agent-uuid",
scheduleTime: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
});
console.log(`Conversation jobId: ${conversation.jobId}`);
```
The inline job is saved as a regular **Job**. You can fetch and manage it afterwards through
the Jobs API using the `jobId` returned on the conversation.
## Test Mode
Test mode creates a conversation that completes immediately and consumes no billing minutes. It exists so you can exercise webhook handlers and integration flows without placing a call.
### When to Use Test Mode
* **Testing webhook handlers**: check that your endpoint handles `conversation.completed` and `analysis.completed` correctly
* **Integration testing**: exercise your processing logic without waiting for a real call
* **Development**: build against completed conversations on demand
* **Demos**: create sample conversations to show
### How Test Mode Works
When you set `isTest: true`:
1. **Billing bypass**: all billing checks are skipped — no minutes are consumed
2. **Immediate completion**: the conversation is created with `COMPLETED` status straight away
3. **Mock analysis**: realistic analysis data is generated and stored
4. **Webhook events**: both `conversation.completed` and `analysis.completed` fire
5. **Default duration**: 5 minutes
The event names above are the default `NEUTRAL` vocabulary. A webhook registered before that
option existed is on `LEGACY` and receives `interview.completed` instead — same payload, same
trigger. See [Event name vocabulary](/guides/webhooks#event-name-vocabulary).
### Example: Creating a Test Conversation
```javascript theme={null}
const testConversation = await createConversation({
contact: {
firstName: "Jane",
lastName: "Doe",
email: "jane@example.com",
phoneNumber: "+1234567890",
gdprExpiryDate: "2026-11-16",
},
agentId: "agent-uuid",
isTest: true, // Enable test mode
});
console.log(`Test conversation created: ${testConversation.id}`);
console.log(`Status: ${testConversation.status}`); // "COMPLETED"
console.log(`Duration: ${testConversation.durationMinutes} minutes`); // 5
```
### Test Conversation Characteristics
* **Status**: always `COMPLETED` immediately
* **Duration**: 5 minutes (300 seconds)
* **Analysis**: mock analysis, including:
* conversation check results (`candidateInterest: true`, `conversationCompleted: true`)
* analysis scores and recommendations
* mock transcript segments
* **Webhooks**: fires `conversation.completed` and `analysis.completed`
* **Billing**: no minutes consumed, no billing checks performed
Test conversations carry `isTest: true`. They appear in listings and can be queried like any other, but they do not affect billing or production metrics.
## Related Resources
Learn how conversations are run and analysed
Configure the agents that run them
Manage the people you call
Understand call costs and limits
Learn about webhook events and testing
# Delete Call Attempt
Source: https://docs.instaview.sk/api-reference/conversations/delete-call-attempt
DELETE /conversations/{id}/call-attempts/{attemptId}
Cancels and deletes a specific call attempt. Blocked for ONLINE conversations.
`DELETE /interviews/{id}/call-attempts/{attemptId}` is a permanent alias of this endpoint and keeps working unchanged, with the same scopes and the same response. See [Resource names](/api-reference/introduction#resource-names).
Cancels and permanently deletes a specific call attempt.
**ONLINE conversations**: managing individual call attempts is forbidden on an `ONLINE` conversation.
## Overview
Deleting a call attempt removes it from the conversation's history, and cancels it if it was scheduled for the future.
## Constraints
* **In-progress protection**: as with rescheduling, deleting an attempt that is `IN_PROGRESS` is blocked.
* **The conversation survives**: deleting attempts does not delete the conversation. To remove the conversation and all its data, use [Delete Conversation](/api-reference/conversations/delete-conversation).
**Use case**: clearing failed or redundant attempts out of a conversation's history, or cancelling a pending retry that is no longer wanted.
## Webhooks
This endpoint emits **no webhook**. It cancels a single call attempt without changing the conversation's own status, so no conversation-level lifecycle event applies — in particular, no [`conversation.cancelled`](/guides/webhooks#conversation-cancelled-payload) is sent.
Deleting the **last remaining scheduled attempt** leaves the conversation `SCHEDULED` with no
future call queued, and emits no event. If your system treats a conversation as open until a
terminal event arrives, either follow this call with [Delete
Conversation](/api-reference/conversations/delete-conversation), or schedule a replacement with
[Invoke New Call Attempt](/api-reference/conversations/invoke-call-attempt).
# Delete Conversation
Source: https://docs.instaview.sk/api-reference/conversations/delete-conversation
DELETE /conversations/{id}
Permanently deletes a conversation and all associated data (transcripts, recordings, and analyses). This action is irreversible. A scheduled conversation is removed from the processing queue. For one already in progress, the active call continues to its natural end, but all post-call processing is skipped and concurrency slots are released.
`DELETE /interviews/{id}` is a permanent alias of this endpoint and keeps working unchanged, with the same scopes and the same response. See [Resource names](/api-reference/introduction#resource-names).
Permanently deletes a conversation and everything recorded with it. **This action cannot be undone.**
## Overview
This endpoint removes a conversation and all of its data — transcripts, recordings, analyses — from your account. Use it for data hygiene, or to clear out test calls.
## Use Cases
* **Drop what is no longer needed**: conversations scheduled and then called off
* **Clean up test calls**: remove test or development conversations from production
* **Data management**: permanently remove a conversation and everything with it
## Impact on Associated Data
**Permanent Deletion**: This action is irreversible. All associated data (transcripts, recordings, and analysis) will be permanently deleted.
## Conversation States
A conversation can be deleted whatever its current status:
| Status | Deletable | Notes |
| - | - | - |
| `SCHEDULED` | ✅ Yes | Removed from the queue automatically |
| `IN_PROGRESS` | ✅ Yes | Call continues, but post-call processing is skipped |
| `COMPLETED` | ✅ Yes | Already finished, analysis available |
| `FAILED` | ✅ Yes | Never completed |
| `CANCELED` | ✅ Yes | Already canceled |
## Scheduled Conversation Behavior
Deleting a **scheduled** conversation removes it from the processing queue, so the call is
never placed. Its scheduled call attempts are marked **CANCELLED** and dequeued with it.
## In-Progress Conversation Behavior
Deleting an **in-progress** conversation lets the live call **run to its natural end**. All
post-call processing is **skipped** once it does.
When you delete an in-progress conversation:
* The active phone call **continues** until it ends naturally
* When the call ends, **no post-call processing** occurs:
* No transcript generation
* No analysis or scoring
* No completion webhooks
* Concurrency slots are released when the call ends
If you want the call's data (transcript, analysis), wait for the conversation to complete
before deleting it. Deleting one mid-call collects nothing at all.
## Webhooks
Deletion emits **no webhook**, including no [`conversation.cancelled`](/guides/webhooks#conversation-cancelled-payload). Deleting removes the resource outright rather than cancelling it, and any of the conversation's webhook deliveries still pending at that moment are cancelled as part of the same operation.
If you rely on a terminal event to close a conversation out in your own system, record the
deletion at the point you issue this call — no event will arrive afterwards, and a subsequent
`GET /conversations/{id}` returns `404`.
## Example Usage
```javascript theme={null}
async function deleteConversation(conversationId) {
const response = await fetch(
`https://api.instaview.sk/conversations/${conversationId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
// Without a deadline a stalled request never settles.
signal: AbortSignal.timeout(10_000),
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.message);
}
const data = await response.json();
return data; // { id, deleted: true }
}
// Usage
const deleted = await deleteConversation("550e8400-e29b-41d4-a716-446655440000");
console.log("Conversation deleted:", deleted);
```
## Company Isolation
You can only delete conversations belonging to your API key's company. Reaching for one from another company returns `403 Forbidden`.
## Required Scopes
This endpoint requires the `delete:conversations` scope (or its legacy alias `delete:interviews`). Ensure your API key has this scope enabled.
## Error Scenarios
| Status Code | Error | Description |
| - | - | - |
| 404 | Not Found | Does not exist, or has already been deleted |
| 403 | Forbidden | Belongs to a different company |
| 401 | Unauthorized | Invalid or missing API key |
| 400 | Bad Request | Malformed conversation ID |
## Related Resources
Learn the conversation lifecycle
View a contact's conversations
Read it before you delete it
Schedule a new call
# Get Conversation
Source: https://docs.instaview.sk/api-reference/conversations/get-conversation
GET /conversations/{id}
Returns a single conversation, ensuring it belongs to the API key's company.
`GET /interviews/{id}` is a permanent alias of this endpoint and keeps working unchanged, with the same scopes and the same response. See [Resource names](/api-reference/introduction#resource-names).
Retrieves a single conversation by ID, including full details, transcript, analysis or analytics, and call attempt information.
## Overview
This endpoint returns everything recorded about one conversation: its current status, the transcript, the AI analysis or analytics when there is any, and every associated field. It is the primary way to read a call's results.
## Use Cases
* **Read results**: analysis, analytics and transcript once the call has finished
* **Check status**: follow a conversation's progress
* **Access transcripts**: the full transcript of what was said
* **Review scoring**: the AI-generated assessment and its scores
## Response Data
The response includes:
* **Conversation details**: status, timestamps, duration, scheduled time
* **Transcript**: the full transcript, when available
* **Analysis**: AI-generated assessment with scores and recommendations
* **Call attempts**: each attempt and its recording
* **Contact & agent**: `contactId` (with `candidateId` alongside it, always identical) and `agentId`
* **Run**: `runId`, the [run](/guides/resources/runs) that produced this conversation, or `null` for one created on its own
* **Agent configuration**: `flowVersion`, a hash identifying the compiled agent configuration the latest call was placed with, and the same field on each call attempt; see [Conversations](/guides/resources/conversations)
## Analysis Data
Once a conversation has completed, the response carries its analysis. The structure is a `general` object holding the overall assessment, plus an optional `specific` object for data particular to the conversation type:
```json theme={null}
{
"analysis": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"general": {
"overallRating": 85,
"companyFitRating": "HIGH",
"education": "MASTER_LEVEL",
"experience": "THREE_TO_5_YEARS",
"strongPoints": ["Strong technical skills", "Clear communication"],
"weakPoints": ["Limited leadership experience"],
"evaluation": ["Strong hire for senior individual contributor role"],
"status": 3,
"createdAt": "2024-01-20T14:30:00Z",
"updatedAt": "2024-01-20T14:30:00Z"
},
"specific": null
}
}
```
The `specific` field carries data particular to the conversation type, when there is any. See the [Conversations Resource Guide](/guides/resources/conversations#conversation-types--analysis) for what each type produces.
Analysis is only available once the conversation has completed. While one is scheduled or
in progress, `analysis` is `null`.
## Custom Agent Analytics
A conversation run by a [custom agent](/api-reference/agents/create-agent#custom-agents) carries a second, separate object: `analytics`, containing whatever that agent's `analyticsConfig` asked for. It is **not** a variant of `analysis` — `analysis` is the recruiting pipeline's output and is typically absent on a custom call, while `analytics` is the agent's own design.
```json theme={null}
{
"analytics": {
"matchScore": 72,
"fields": [
{
"key": "budget_confirmed",
"label": "Budget confirmed",
"type": "bool",
"value": true,
"confidence": 0.9,
"evidence": "Said they have signed off on 15k for this quarter"
},
{ "key": "timeline", "label": "Timeline", "type": "string", "value": null }
],
"scoring": {
"total": 72,
"items": [
{
"key": "budget_confirmed",
"kind": "field",
"label": "Budget confirmed",
"score": 90,
"weightPercent": 50,
"importance": "REQUIRED"
}
]
},
"outcomes": [{ "key": "meeting_booked", "label": "Meeting booked", "met": true }],
"tags": [{ "key": "needs_followup", "label": "Needs follow-up", "applied": false }],
"qa": [
{
"id": "3f1a8c2e-question-uuid",
"question": "What are you using today?",
"answered": true,
"answer": "Spreadsheets, rebuilt by hand every week",
"evidence": "It's the worst two hours of my Monday"
}
],
"evaluation": {
"strengths": ["Clear pain point, already shopping for a replacement"],
"concerns": ["Finance sign-off not secured"],
"objections": ["Worried about migrating two years of historical data"],
"assessment": ["Strong fit; the only real risk is procurement timing"]
},
"sentiment": { "overall": "positive", "score": 0.8, "trajectory": "improving" },
"summary": "Qualified lead; demo booked for Thursday.",
"updatedAt": "2026-08-03T14:30:00Z"
}
}
```
The `key` on each field, outcome and tag is the one configured on the agent, so you can switch on it directly. Reading it back:
* `value: null` on a field means the call did not surface it — distinct from a `false` or empty value that **was** surfaced.
* In `scoring.items`, an entry with `kind: "question"` carries the flow question's **id** rather than a configured key — a question has none, which is why its scoring is set inline on the question. Entries with `kind: "field"` or `"criterion"` carry the configured key.
* `weightPercent` is each scored item's share of the match score, so the shares sum to exactly 100. The weights you configured are relative, not percentages.
* `matchScore` is always present, and `null` when scoring has not run yet.
* In `qa`, `answered: false` means the call never reached the question, not that it was answered with nothing — `answer` is empty in both cases, so switch on `answered`.
* `evaluation` holds four independent arrays of lines (`strengths`, `concerns`, `objections`, `assessment`); any one can be empty while the others are populated.
* Each slice is produced by its own after-call job, and each job only runs when the agent asked for it — so an absent `outcomes` or `sentiment` means "not configured, or not run yet".
The same object is pushed to your `analysis.completed` [webhook](/guides/webhooks), so you
do not have to poll for it.
## Status Checking
```javascript theme={null}
async function checkConversationStatus(conversationId) {
const response = await fetch(
`https://api.instaview.sk/conversations/${conversationId}`,
{
headers: { Authorization: `Bearer ${apiKey}` },
// Without a deadline a stalled request never settles.
signal: AbortSignal.timeout(10_000),
},
);
if (!response.ok) {
throw new Error(
`Reading the conversation failed: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
return {
status: data.status,
hasAnalysis: data.analysis !== null,
durationMinutes: data.durationMinutes,
finishedDate: data.finishedDate,
};
}
```
## Company Isolation
You can only read conversations belonging to contacts in your own company. Reaching for one from another company returns `403 Forbidden`.
## Error Scenarios
* **404 Not Found**: the conversation does not exist, or has been deleted
* **403 Forbidden**: the conversation belongs to a different company
## Related Resources
Learn how conversations are run and analysed
List every conversation for one contact
Schedule a new call
Download the analysis PDF report
# Get Conversation Analysis PDF
Source: https://docs.instaview.sk/api-reference/conversations/get-conversation-analysis-pdf
GET /conversations/{id}/analysis-pdf
Returns the Base64-encoded PDF report of a conversation's analysis. The conversation must have a completed analysis for the PDF to be available.
`GET /interviews/{id}/analysis-pdf` is a permanent alias of this endpoint and keeps working unchanged, with the same scopes and the same response. See [Resource names](/api-reference/introduction#resource-names).
Returns the Base64-encoded PDF report of a conversation's analysis.
## Overview
The PDF comes back as a Base64-encoded string. It is kept off the conversation resource itself so that `GET /conversations/:id` stays lightweight — fetch it here when you need the formatted report to download, forward or archive.
## Use Cases
* **Download the report**: the formatted analysis of one conversation
* **Forward it**: email or share it with whoever needs to read it
* **Archival**: store it alongside your own records
## Prerequisites
The conversation must have a **completed analysis** for a PDF to exist. While one is still in progress, or if it has no analysis, this endpoint returns `404`.
## Response
```json theme={null}
{
"analysisPdfBase64": "JVBERi0xLjQKMSAwIG9iai..."
}
```
The `analysisPdfBase64` field contains the full PDF document encoded as a Base64 string. To reconstruct the PDF file, decode the Base64 string into binary data.
## Example Usage
```javascript theme={null}
async function downloadAnalysisPdf(conversationId) {
const response = await fetch(
`https://api.instaview.sk/conversations/${conversationId}/analysis-pdf`,
{ headers: { Authorization: `Bearer ${apiKey}` } },
);
const data = await response.json();
// Decode Base64 to binary
const binaryString = atob(data.analysisPdfBase64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// Create downloadable file
const blob = new Blob([bytes], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
// Use `url` to trigger download or embed in an iframe
}
```
## Error Scenarios
* **404 Not Found**: the conversation does not exist, has been deleted, or has no analysis yet
* **403 Forbidden**: the conversation belongs to a different company
The analysis PDF is also included in the `analysis.completed` [webhook
payload](/guides/webhooks#analysis-events-payload) as the `analysisPdfBase64`
field, so you may not need this endpoint if you are already consuming
webhooks.
## Related Resources
Read the whole conversation, analysis included
Receive analysis PDF automatically via webhooks
# Invoke New Call Attempt
Source: https://docs.instaview.sk/api-reference/conversations/invoke-call-attempt
POST /conversations/{id}/call-attempts
Creates a new call attempt on a conversation. Optionally schedules it for a future time. Individual call attempt management is forbidden for ONLINE conversations.
`POST /interviews/{id}/call-attempts` is a permanent alias of this endpoint and keeps working unchanged, with the same scopes and the same response. See [Resource names](/api-reference/introduction#resource-names).
Creates a new call attempt on an existing phone conversation — a way to place a call by hand, or to schedule a retry after an attempt that did not connect.
**ONLINE conversations**: managing individual call attempts is forbidden on an `ONLINE` conversation. There, the whole conversation is the unit — use [Delete Conversation](/api-reference/conversations/delete-conversation).
## Overview
This endpoint places a new call on a phone conversation. It is useful when:
* A previous call attempt failed (e.g., no answer, busy) and you want to try again manually.
* You want to override the automatic retry logic and trigger a call immediately.
* You want to schedule a specific time for the next call attempt.
## Constraints and Behavior
* **Queue management**: invoking a new attempt cancels any attempt already scheduled on that conversation and replaces it.
* **In-progress protection**: while a call is in progress on the conversation, a new attempt cannot be invoked.
* **Transactional Integrity**: The creation of a call attempt and its synchronization with the processing queue is handled transactionally.
## Examples
### Immediate Call
Omit `scheduledAt` to trigger a call attempt as soon as possible:
```json theme={null}
{
"scheduledAt": null
}
```
### Scheduled Call Attempt
Provide a future ISO 8601 timestamp to schedule the call for a specific time:
```json theme={null}
{
"scheduledAt": "2026-05-01T10:00:00Z"
}
```
**This endpoint does not apply the agent's [calling
window](/api-reference/agents/create-agent#retries-and-calling-hours) either way.** A
`scheduledAt` you send is used exactly as given, including when it falls outside those hours.
Omitting it dials as soon as capacity allows, which is the point of an explicit invoke, so that
is not held back to the window either. Both are deliberate: you are asking for this call now,
and the window governs the calls we schedule ourselves.
**Next steps**: follow the attempt by fetching the conversation through [Get Conversation](/api-reference/conversations/get-conversation) and reading its `callAttempts` array.
# List Conversations
Source: https://docs.instaview.sk/api-reference/conversations/list-conversations
GET /conversations
Lists conversations within the API key's company. Every filter is optional: pass `contactId` to narrow the page to one contact (`candidateId` is accepted as its legacy alias), and omit it to list all of them.
`GET /interviews` is a permanent alias of this endpoint and keeps working unchanged, with the same scopes and the same response. See [Resource names](/api-reference/introduction#resource-names).
Lists conversations with pagination and filtering.
## Overview
This endpoint returns the conversations in your API key's company — useful for showing someone's call history, following a conversation's status, and picking up analysis or analytics once a call has finished.
## Filters
Every filter is optional; omit them all and you get the whole company's conversations, newest first.
| Query parameter | Filters on |
| - | - |
| `contactId` | One contact. `candidateId` is accepted as its permanent legacy alias |
| `agentId` | One agent |
| `jobId` | The job the conversation itself is for (deprecated) |
| `status` | One [status](#status-filtering) |
## Use Cases
* **Call history**: every conversation for one contact
* **Status tracking**: follow progress and completion
* **Results retrieval**: reach analysis, analytics and transcripts
* **Pipeline management**: track conversations across your own process
## Basic Usage
```javascript theme={null}
// List all conversations for a contact
GET /conversations?contactId=contact-uuid
```
## With Pagination
```javascript theme={null}
// Get first page with 20 items
GET /conversations?contactId=contact-uuid&page=1&limit=20
// Get second page
GET /conversations?contactId=contact-uuid&page=2&limit=20
```
## Status Filtering
`status` takes one of `SCHEDULED`, `IN_PROGRESS`, `COMPLETED`, `CANCELLED`, `FAILED`,
`UNREACHABLE` or `UNDEFINED`. The values are upper-case, and anything else is rejected with
`400 Bad Request` before the listing runs.
```javascript theme={null}
// Get only completed conversations
GET /conversations?contactId=contact-uuid&status=COMPLETED
```
## Response Structure
Each row carries the conversation's own fields, plus analysis when there is any:
```json theme={null}
{
"data": [
{
"id": "conversation-uuid",
"contactId": "contact-uuid",
"candidateId": "contact-uuid",
"agentId": "agent-uuid",
"status": "COMPLETED",
"analysis": {
"id": "analysis-uuid",
"general": {
"overallRating": 85,
"companyFitRating": "HIGH",
"strongPoints": ["Strong technical skills"],
"weakPoints": ["Limited leadership experience"]
},
"specific": null
}
}
],
"total": 5,
"page": 1,
"limit": 20,
"totalPages": 1
}
```
Both id spellings are always present and always identical, so you can move field by field at your own pace.
Rows produced by a [custom agent](/api-reference/agents/create-agent#custom-agents) carry an [`analytics`](/api-reference/conversations/get-conversation#custom-agent-analytics) object instead — the same shape returned by `GET /conversations/{id}`, so a page needs no follow-up request per row to read its results. Such rows typically have **no** `analysis`, since the recruiting jobs do not run for them.
## Tracking Progress
```javascript theme={null}
async function trackConversations(contactId) {
const response = await fetch(
`https://api.instaview.sk/conversations?contactId=${contactId}`,
{
headers: { Authorization: `Bearer ${apiKey}` },
// Without a deadline a stalled request never settles.
signal: AbortSignal.timeout(10_000),
},
);
if (!response.ok) {
throw new Error(
`Listing conversations failed: ${response.status} ${response.statusText}`,
);
}
const { data, total } = await response.json();
return {
total,
completed: data.filter((c) => c.status === "COMPLETED").length,
inProgress: data.filter((c) => c.status === "IN_PROGRESS").length,
scheduled: data.filter((c) => c.status === "SCHEDULED").length,
};
}
```
## Company Isolation
Every conversation returned belongs to a contact in your API key's company. Conversations from another company are never listed.
## Related Resources
Learn how conversations are run and analysed
Retrieve a single conversation by ID
Understand pagination best practices
Manage the people you call
# Reschedule Call Attempt
Source: https://docs.instaview.sk/api-reference/conversations/reschedule-call-attempt
PATCH /conversations/{id}/call-attempts/{attemptId}
Reschedules an existing call attempt to a future time. Only call attempts in SCHEDULED status can be rescheduled. Blocked for ONLINE conversations.
`PATCH /interviews/{id}/call-attempts/{attemptId}` is a permanent alias of this endpoint and keeps working unchanged, with the same scopes and the same response. See [Resource names](/api-reference/introduction#resource-names).
Updates the scheduled time for an existing call attempt that hasn't started yet.
**ONLINE conversations**: managing individual call attempts is forbidden on an `ONLINE` conversation.
## Overview
Use this endpoint to move a scheduled call attempt to a different time — most often because the contact asked for a particular slot.
## Restrictions
* **Status Restriction**: You can only reschedule call attempts that are in `SCHEDULED` status. Attempts that are in any other state (e.g., `IN_PROGRESS`, `COMPLETED`, `FAILED`, `CANCELLED`) cannot be rescheduled.
* **In-Progress Protection**: If the attempt is currently being processed (status `IN_PROGRESS`), the request will be blocked with a `400 Bad Request` error to prevent race conditions.
* **Future Time**: The `scheduledAt` time must be in the future.
* **Calling window**: the time you send is used exactly as given, including when it falls outside the agent's [calling window](/api-reference/agents/create-agent#retries-and-calling-hours). Rescheduling is you naming a time, which is usually a slot the contact asked for, so it overrides the agent's hours rather than being moved into them.
## Effect on the conversation
Moving an attempt also moves the conversation it belongs to: `status` becomes `SCHEDULED`, because an attempt is queued and waiting again. If the conversation had already settled on a terminal status, rescheduling reopens it — the status returns to `SCHEDULED` and `finishedDate` is cleared, under the same id. See [Reopening a terminal conversation](/guides/resources/conversations#reopening-a-terminal-conversation).
## Webhooks
A successful reschedule emits [`conversation.rescheduled`](/guides/webhooks#conversation-rescheduled-payload) with `reason: "API_REQUEST"`, once the change is committed. A webhook still on the `LEGACY` [vocabulary](/guides/webhooks#event-name-vocabulary) receives the same payload as `interview.rescheduled`.
Because this endpoint **moves an existing attempt** rather than ending one and creating another, the same `callAttemptId` appears in both `previousAttempt` and `nextAttempt`, and `previousAttempt.status` is `SCHEDULED` — no call took place. This is the only case where the two IDs match; for every other reason they differ.
```json theme={null}
{
"event": "conversation.rescheduled",
"data": {
"reason": "API_REQUEST",
"previousAttempt": {
"callAttemptId": "call-attempt-uuid",
"status": "SCHEDULED",
"attemptNumber": 1
},
"nextAttempt": {
"callAttemptId": "call-attempt-uuid",
"scheduledAt": "2026-05-01T14:30:00.000Z",
"attemptNumber": 1
}
}
}
```
A `reason` of `ADMIN` on this event means InstaView staff moved the attempt
from the internal admin panel rather than you moving it through the API —
worth surfacing differently in your system.
## Example
```json theme={null}
{
"scheduledAt": "2026-05-01T14:30:00Z"
}
```
**Queue Sync**: The system automatically synchronizes with the internal processing queue when an attempt is rescheduled, ensuring it runs at the new specified time.
# API Reference
Source: https://docs.instaview.sk/api-reference/introduction
Complete API reference for the InstaView API
The InstaView API gives you programmatic access to agents, contacts, conversations, runs, jobs, companies and billing. Every endpoint requires API key authentication.
## Base URL
All API requests should be made to:
```
https://api.instaview.sk
```
## Authentication
All API requests require an API key in the Authorization header using the Bearer scheme:
```
Authorization: Bearer sk_your_api_key_here
```
For more information about authentication and API keys, see the [Authentication Guide](/authentication).
## Resources
The API is organized around the following resources:
* **[Agents](/api-reference/agents/list-agents)** - The AI that runs the call
* **[Contacts](/api-reference/contacts/list-contacts)** - The people you call
* **[Conversations](/api-reference/conversations/list-conversations)** - One call each, with its transcript and results
* **[Runs](/api-reference/runs/get-run)** - A batch of conversations one agent is working through
* **[Jobs](/api-reference/jobs/list-jobs)** - Job postings, for the hiring focuses that need one
* **[Companies](/api-reference/companies/list-companies)** - Company information (ATS keys only)
* **[Billing](/api-reference/billing/get-usage)** - Usage statistics and billing information
## Resource names
Two resources answer to two names. InstaView started in hiring, so its API was written in hiring nouns; agents now run sales, support and operations calls, where "interview" and "candidate" describe the wrong thing. Rather than cut a v2 whose only content is a rename, both names work:
| Current name | Legacy alias | What it is |
| - | - | - |
| `/conversations` | `/interviews` | One call, phone or web |
| `/contacts` | `/candidates` | The person on the other end |
| `contactId` | `candidateId` | The contact a conversation belongs to |
| `read:conversations` | `read:interviews` | The scope on your API key (same for `write:` and `delete:`) |
| `read:contacts` | `read:candidates` | As above, for contacts |
| `conversation.completed` | `interview.completed` | The webhook event, and its `started`, `failed`, `rescheduled` and `cancelled` siblings |
**Nothing you have written stops working.** The legacy paths are served by the same handlers, not a redirect: same request, same response, same scopes, same rate limits. They stay for the life of `v1`. If we ever cut a `v2`, it will be for a change worth migrating for, and the aliases will go with it.
Three details worth knowing:
* **Responses carry both id names**, always identical. A conversation returns `contactId` and `candidateId` with the same value, so you can move at your own pace, field by field.
* **Requests take either**, but not both with different values. Sending `contactId` and `candidateId` set to two different ids is a `400` rather than a guess about which you meant. The same rule applies to the inline `contact` / `candidate` body on [Create Conversation](/api-reference/conversations/create-conversation).
* **Either scope name works.** `read:conversations` and `read:interviews` are the same permission, and holding one satisfies a requirement for the other — in both directions, for `read:`, `write:` and `delete:` alike. Your existing key keeps the scopes it already has and keeps working; there is nothing to migrate and no date by which to do it. Keys created from now on are issued with the neutral names, which is all that changed. See [Scopes and Permissions](/guides/scopes-and-permissions).
`jobs` and `agents` are unchanged: `agent` is already neutral, and `job` has no better generic
equivalent and is optional on a conversation anyway. So is `analysis`, along with `sourcing` and
`enrich` — none of them was ever hiring-specific.
A few names inside those resources kept their hiring spelling too, because they are the names the
API accepts and returns. There is no alias for these, and nothing to migrate:
| Name | Where | Why it stayed |
| - | - | - |
| `candidatePosition` | `workHistory` entries on a contact | A field of a work-history record, not of the resource |
| `APPLIED`, `IN_PROCESS`, `REJECTED`, `ACCEPTED` | `status` on a contact | Enum values a caller may already be sending |
| `interviewCount` | Declared on the contact schema | Never populated by any endpoint; see [Get Contact](/api-reference/contacts/get-contact) |
| Error message text | Every `4xx` | Integrations match on these strings, so their wording is left alone |
## Response Format
A successful response returns the resource itself, with no wrapper around it:
```json theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Renewal check-in"
}
```
List endpoints return a page object — the items in `data`, the counts beside it. See the
[Pagination Guide](/guides/pagination).
Whether a call succeeded is carried by the HTTP status code, not by a field in the body.
Every response also carries an `x-trace-id` header; quote it when you write to support.
Responses used to be wrapped in `{ statusCode, message, data, traceId }`, which put list
items at `data.data`. Integrations that predate this keep receiving the old shape until
**4 September 2026, 00:00 Europe/Prague**, and can tell because their responses carry a
`Sunset` header until then. See the [changelog](/changelog/2026-08) for the
before-and-after and what to change in your client.
## Rate Limiting
API requests are rate-limited to ensure fair usage. See the [Rate Limiting Guide](/guides/rate-limiting) for details.
## Pagination
List endpoints support pagination using `page` and `limit` query parameters. See the [Pagination Guide](/guides/pagination) for details.
## Error Handling
The API uses standard HTTP status codes and returns error details in a consistent format. See the [Error Handling Guide](/guides/error-handling) for details.
## Interactive Playground
Each endpoint documentation includes an interactive playground where you can test API calls directly from your browser. Look for the **"Try It"** button on any endpoint page.
# Create Job
Source: https://docs.instaview.sk/api-reference/jobs/create-job
POST /jobs
Creates a new job scoped to the API key's company.
Creates a job posting scoped to the API key's company. A job groups contacts, and the hiring focuses that need one require it; outside hiring a conversation needs no job at all.
## Overview
The create job endpoint allows you to add new job postings to your recruitment pipeline. Each job must include at minimum a title, description, and status. Jobs can be enhanced with skills requirements, location information, salary details, and more.
## Use Cases
* **Job Posting Creation**: Add new positions to your recruitment system
* **ATS Integration**: Sync jobs from external ATS systems
* **Bulk Job Import**: Import multiple job postings
* **Job Template Creation**: Create job templates for recurring positions
## Basic Job Creation
```javascript theme={null}
{
"title": "Senior Software Engineer",
"description": "We are seeking an experienced software engineer...",
"requiredSkills": ["JavaScript", "React", "Node.js"],
"status": "OPEN"
}
```
## Company Scoping
Jobs are scoped to your API key's company, and so is everything attached to one.
## Status Management
Jobs start with a status (typically `OPEN` for active positions). You can update the status later using the [Update Job](/api-reference/jobs/update-job) endpoint.
## Related Resources
Comprehensive guide to job management, including skills, location, and salary configuration
Add contacts to your jobs
View all your job postings
# Create Job from URL
Source: https://docs.instaview.sk/api-reference/jobs/create-job-from-url
POST /jobs/url
Scrapes the provided job posting URL and extracts structured data. By default, creates a job entity from the extracted data. Set createJob to false to only extract data without persisting a job.
Extracts structured data from a job posting URL and optionally creates a job entity. The endpoint extracts job details (title, description, skills, salary, location, etc.) and by default creates a job in a single operation. Set `createJob` to `false` to only extract data without persisting.
## Overview
This endpoint is ideal for quickly importing job postings from external sources without manually entering all the fields. You can also use it in extraction-only mode to preview the extracted data before deciding to create a job.
## Use Cases
* **Quick Job Import**: Import job postings from job boards or career pages with a single URL
* **Job Aggregation**: Aggregate jobs from multiple sources programmatically
* **Data Preview**: Extract and review job data before creating a job by setting `createJob` to `false`
## How It Works
1. **Scraping**: The provided URL is fetched and the HTML content is cleaned
2. **Extraction**: Structured job data is extracted (title, description, skills, salary, location, etc.)
3. **Job Creation**: A job entity is created with the extracted data (skipped when `createJob` is `false`)
## Request
```json theme={null}
{
"jobUrl": "https://example.com/careers/senior-developer"
}
```
### Extraction Only (without creating a job)
```json theme={null}
{
"jobUrl": "https://example.com/careers/senior-developer",
"createJob": false
}
```
When `createJob` is `false`, the endpoint returns **200 OK** (instead of **201 Created**) and the response `data` field contains a preview of the job with all extracted fields populated — the same structure as a created job, but `id`, `createdAt`, and `updatedAt` will be empty strings since no entity was persisted. This lets you inspect the extraction results before deciding to create.
## Rate Limiting
This endpoint has a stricter rate limit of **5 requests per minute**.
## Error Handling
If the URL cannot be scraped or no job data can be extracted:
```json theme={null}
{
"statusCode": 400,
"message": "Failed to extract job data from the provided URL"
}
```
If a valid job title (min 5 characters) cannot be determined from the URL:
```json theme={null}
{
"statusCode": 400,
"message": "Could not extract a valid job title from the URL. A job title of at least 5 characters is required."
}
```
```json theme={null}
{
"statusCode": 400,
"message": "Please provide a valid URL"
}
```
## Related Resources
Create a job manually with full control over all fields
Comprehensive guide to job management
View all your job postings
Understand API rate limits
# Delete Job
Source: https://docs.instaview.sk/api-reference/jobs/delete-job
DELETE /jobs/{id}
Permanently deletes a job that belongs to the API key's company. This action cannot be undone, and the delete cascades: the job's contacts and their conversations are deleted with it, not just the assignments between them. Set status to CLOSED instead to retire a job without losing data.
Permanently deletes a job posting, **and every contact and conversation attached to it**. This action cannot be undone.
## Overview
This endpoint removes a job posting from your account. It is the widest-reaching delete in the public API: the cascade takes the job's contacts and their conversations with it, not just the links between them.
## Use Cases
* **Remove Obsolete Jobs**: Clean up jobs that are no longer relevant
* **Archive Old Postings**: Permanently remove outdated job postings
* **Compliance**: Delete jobs for data retention compliance
* **Organization**: Maintain a clean job library
## Permanent Deletion Behavior
Deletion is irreversible, and it removes the job's language requirements and job links along with the posting itself.
## Impact on Existing Data
**The cascade deletes contacts and conversations.** Every contact whose job this is goes, and
so does every conversation of theirs — transcripts, recordings and analyses included. A
contact assigned to this job *and* others is deleted too: the cascade is by job, not by
"contacts left with no job". None of it is readable through the API afterwards.
If you only want to stop using a job, set its `status` to `CLOSED` instead — that changes
nothing else. Delete it only when you intend to lose the data attached to it.
## Company Isolation
You can only delete jobs that belong to your API key's company. A job from another company answers `404 Not Found` — the same status and the same body as an id that exists nowhere, so the response never confirms that an id exists somewhere else.
## Error Scenarios
* **404 Not Found**: No job with that id is available to your API key. It does not exist, it has already been deleted, or it belongs to another company — the three are deliberately indistinguishable, so do not branch on this status to detect a permission problem.
This route does not treat a delete of an unknown id as a no-op success. It used to answer `200` with `"data": false` for an id that never existed; it now answers `404`, matching `DELETE /agents/{id}`. A `200` means a job was really deleted.
## Related Resources
Learn about job management
View all active jobs
Create new job postings
# Get Job
Source: https://docs.instaview.sk/api-reference/jobs/get-job
GET /jobs/{id}
Returns a single job, ensuring it belongs to the API key's company.
Retrieves a specific job posting by ID, including all details such as skills requirements, location, salary, and status.
## Overview
Returns everything held about one job posting — useful before attaching a contact to it, for checking its configuration, or for displaying it in your own application.
## Use Cases
* **Job Details Display**: Show complete job information in your UI
* **Pre-flight check**: confirm the job exists and is reachable by your key before attaching contacts
* **Job Configuration Review**: Inspect job settings and requirements
* **Integration Validation**: Confirm job details match external systems
## Response Data
The response includes all job information:
* Basic details (title, description, status)
* Skills requirements (required and nice-to-have)
* Location and work mode
* Salary information
* Language requirements
* Timestamps (created, updated)
## Company Isolation
You can only access jobs that belong to your API key's company. A job from another company answers `404 Not Found` — the same status and the same body as an id that exists nowhere, so the response never confirms that an id exists somewhere else.
## Error Scenarios
* **404 Not Found**: No job with that id is available to your API key. It does not exist, it has been deleted, or it belongs to another company — the three are deliberately indistinguishable, so do not branch on this status to detect a permission problem.
## Related Resources
Learn about job structure and configuration
Modify job details
Browse all job postings
# List Jobs
Source: https://docs.instaview.sk/api-reference/jobs/list-jobs
GET /jobs
Lists jobs for the API key's company with pagination.
Lists all job postings for the API key's company with pagination and filtering support.
## Overview
Returns every job posting in your company. Filter by status, search by title or description, and page through the results — useful for managing your job list and for picking a job before attaching contacts to it.
## Use Cases
* **Job Management**: Review all active job postings
* **Status Filtering**: Find jobs by status (OPEN, CLOSED)
* **Search**: Locate specific jobs by title or description
* **Bulk Operations**: Retrieve jobs for assigning contacts in bulk
## Basic Usage
```javascript theme={null}
// List all jobs (first page, 20 items)
GET /jobs
```
## With Filters
```javascript theme={null}
// Filter by status
GET /jobs?status=OPEN
// Search by title or description
GET /jobs?search=engineer
// Combine filters with pagination
GET /jobs?status=OPEN&search=software&page=1&limit=50
```
## Company Scoping
All returned jobs belong to your API key's company. ATS keys can filter by `companyId` to access jobs from specific companies they manage.
## Related Resources
Learn about job management and filtering options
Understand pagination best practices
Retrieve a specific job by ID
# Update Job
Source: https://docs.instaview.sk/api-reference/jobs/update-job
PATCH /jobs/{id}
Updates an existing job scoped to the API key's company.
Updates an existing job posting using partial updates. Only provided fields will be updated; omitted fields remain unchanged.
## Overview
The update job endpoint allows you to modify job details without recreating the job. This supports partial updates, meaning you only need to provide the fields you want to change. Useful for updating job descriptions, changing status, adjusting salary, or modifying skills requirements.
## Use Cases
* **Status Updates**: Change job status (OPEN to CLOSED, etc.)
* **Content Updates**: Update job description or requirements
* **Salary Adjustments**: Modify salary ranges as needed
* **Skills Refinement**: Update required or nice-to-have skills
## Partial Updates
You can update any combination of fields:
```javascript theme={null}
// Update only status
PATCH /jobs/{id}
{
"status": "CLOSED"
}
// Update multiple fields
PATCH /jobs/{id}
{
"title": "Updated Job Title",
"description": "Updated description",
"requiredSkills": ["New", "Skills", "List"]
}
```
## Status Transitions
Jobs can transition between statuses:
* `OPEN` → `CLOSED`: Position filled or paused
* `CLOSED` → `OPEN`: Reopening a position
Status changes affect nothing else. Contacts stay associated with the job whatever its status, and their conversations are untouched. Closing a job is the safe way to retire it — unlike [deleting it](/api-reference/jobs/delete-job), which takes its contacts and conversations with it.
## Company Isolation
You can only update jobs that belong to your API key's company. A job from another company answers `404 Not Found` — the same status and the same body as an id that exists nowhere, so the response never confirms that an id exists somewhere else.
## Error Scenarios
* **404 Not Found**: No job with that id is available to your API key. It does not exist, it has been deleted, or it belongs to another company — the three are deliberately indistinguishable, so do not branch on this status to detect a permission problem.
* **400 Bad Request**: Invalid field values or validation errors
## Related Resources
Learn about job management and status workflows
Retrieve current job configuration
Create new job postings
# List Phone Numbers
Source: https://docs.instaview.sk/api-reference/phone-numbers/list-phone-numbers
GET /phone-numbers
Lists active phone numbers assigned to the API key's company. Use the returned `id` when referencing a phone number in an agent configuration. ATS keys must supply the `companyId` query parameter.
Lists all active phone numbers assigned to the API key's company.
## Overview
The list phone numbers endpoint returns all phone numbers currently assigned to your company. Use the phone number `id` field when configuring agents for outbound or inbound calls.
## Use Cases
* **Agent Configuration**: Retrieve phone number IDs before creating or updating agents
* **Number Inventory**: Audit which numbers are assigned to your company
* **Capability Check**: Verify whether numbers support outbound, inbound, or both call directions
## ATS Keys
ATS partner keys must supply the `companyId` query parameter to specify which child company's phone numbers to list. Regular company API keys automatically scope the response to their own company.
## Basic Usage
```
GET /phone-numbers
```
## With Pagination
```javascript theme={null}
// Get a specific page
GET /phone-numbers?page=2&limit=50
// Response includes pagination metadata
{
"data": [...],
"total": 5,
"page": 2,
"limit": 50,
"totalPages": 1
}
```
## Finding a Phone Number for an Agent
```javascript theme={null}
// Retrieve phone numbers and pick one for agent configuration
async function getPhoneNumberForAgent() {
const response = await fetch("https://api.instaview.sk/phone-numbers", {
headers: { Authorization: `Bearer ${apiKey}` },
});
const { data } = await response.json();
// Use the `id` field when creating or updating an agent
const outbound = data.find(
(pn) =>
pn.capabilities === "OUTBOUND" || pn.capabilities === "INBOUND_OUTBOUND",
);
return outbound?.id;
}
```
## ATS Key Example
```javascript theme={null}
// ATS partner key — scope to a specific child company
GET /phone-numbers?companyId=123e4567-e89b-12d3-a456-426614174000
```
# Attach Contacts
Source: https://docs.instaview.sk/api-reference/runs/attach-run-contacts
POST /runs/{id}/contacts
Attaches existing contacts to a run. Idempotent - an id already on the run is skipped, so a repeated or overlapping batch attaches nothing twice. Allowed in every status except CANCELLED. On an already-launched run the new contacts are dispatched immediately and a COMPLETED run reopens to RUNNING; on a paused run nothing is dialled until you resume, and on a scheduled run they wait and go out with the rest at scheduledAt. More than the per-request cap is a 400 rather than a silent truncation - chunk it, which is safe.
Attaches existing contacts to a run.
## Overview
Contacts arrive here rather than at [create](/api-reference/runs/create-run), and this endpoint is **idempotent**: an id already on the run is skipped, not attached twice. That is what makes retries and overlapping chunks safe.
```javascript theme={null}
POST /runs/789e0123-.../contacts
{ "contactIds": ["contact-a", "contact-b"] }
→ { "attached": 2, "skipped": 0, "totalContacts": 2, "queued": 0 }
```
Send the same body again and you get `{ "attached": 0, "skipped": 2, "totalContacts": 2 }`. The counts report rows **actually changed**, so they tell you what your request did rather than what it asked for.
## Attaching to a run that is already going
This is the point of the endpoint, not an edge case. A run is a rolling sequence, not a frozen batch:
| Run status | What happens |
| - | - |
| `DRAFT` | Contacts are added. Nothing is dialled until you launch. |
| `SCHEDULED` | Contacts are added. Nothing is dialled; they go out with the rest at `scheduledAt`. |
| `RUNNING` | Contacts are added **and dispatched immediately** — `queued` says how many. |
| `PAUSED` | Contacts are added. Nothing is dialled; they go out when you resume. |
| `COMPLETED` | Contacts are added, and the run **reopens to `RUNNING`**. |
| `CANCELLED` | `422`. A cancelled run is deliberately dead. |
Because a `COMPLETED` run reopens, do not treat `COMPLETED` as "this run is finished forever"
if anything in your system still attaches to it.
Attaching into a `RUNNING` run spends money, so it runs the same all-or-nothing billing admission a launch does. Without that, attach would simply be the way around the gate: launch one contact, attach 999.
## Batch bounds
Up to **500 contact ids per request**. More is a `400`, not a silent truncation — a caller told "attached 500" out of 900 has no way to know which 400 were dropped.
Chunking is yours to do and safe to do, precisely because attaching an id twice is a no-op:
```javascript theme={null}
for (const chunk of chunks(contactIds, 500)) {
await attach(runId, chunk); // overlapping or repeated chunks cost nothing
}
```
A run's **total** contact count is deliberately unbounded. Only the per-request batch is capped.
## Contacts must already exist
This endpoint attaches existing contacts; it does not create them. Create them first with [Create Contact](/api-reference/contacts/create-contact). An id that is not an active contact in your company is a `422` naming the ids it could not find, and nothing is attached.
## Error Scenarios
* **400 Bad Request**: more than 500 ids, an empty list, or a malformed id
* **402 Payment Required**: attaching into a live run the company cannot afford. Nothing is attached and nothing is dispatched
* **404 Not Found**: the run does not exist, or belongs to another company
* **422 Unprocessable Entity**: the run is cancelled, or an id is not a contact in this company
* **403 Forbidden**: the API key does not hold `write:runs` and `read:contacts`
## Related Resources
Remove a contact from a draft
Fan the draft out into conversations
Create the people first
How a run batches conversations
# Cancel Run
Source: https://docs.instaview.sk/api-reference/runs/cancel-run
POST /runs/{id}/cancel
Stops the run for good and closes out its un-dialled conversations. Terminal - there is no resume. A call already connected is left to finish; its retry is refused rather than rescheduled.
Stops a run for good.
## Terminal, unlike pause
Cancel closes out the run's un-dialled conversations and there is **no way back** — no resume, and attaching a contact to a cancelled run is a `422`. If you want to stop temporarily, [pause](/api-reference/runs/pause-run) instead.
A call that is already connected is left to finish. Its retry is refused rather than rescheduled, so the run stops growing without cutting somebody off mid-sentence.
```json theme={null}
{ "status": "CANCELLED", "affectedCalls": 30 }
```
`affectedCalls` is how many un-dialled calls were closed out.
## What survives
The conversations the run already produced. They are separate resources: their transcripts, recordings and analysis stay readable at `/conversations/{id}`, with `runId` still populated. Cancelling stops future calls; it does not erase the ones that happened.
## Cancel before you delete
A run must be inert before it can be deleted, and cancel is what makes an active run inert. See [Delete Run](/api-reference/runs/delete-run).
## Error Scenarios
* **404 Not Found**: the run does not exist, or belongs to another company
* **422 Unprocessable Entity**: the run is already cancelled or completed
* **403 Forbidden**: the API key does not hold `write:runs`
## Related Resources
Stop reversibly instead
Remove it once it is inert
# Create Run
Source: https://docs.instaview.sk/api-reference/runs/create-run
POST /runs
Creates an empty draft run for a custom agent. A draft dials nothing: attach contacts, then launch. Contacts are deliberately not accepted here - a retried create would otherwise leave a second draft holding the same list, and that draft is exactly the thing someone launches later 'to be safe'.
Creates an empty draft run for a custom agent.
## Overview
A run is one agent working through a list of contacts. Building one is three steps, and none of them can call anybody twice:
```
POST /runs → an empty draft
POST /runs/{id}/contacts → add contacts (idempotent)
POST /runs/{id}/launch → one conversation per contact
```
A draft dials nothing. Nothing is queued and nobody is called until you launch it.
## Contacts are not accepted here, on purpose
You cannot pass contacts to this endpoint, and that is a deliberate safety property rather than an omission.
If create took a contact list, a request that timed out after the server 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 someone finds later and launches "to be safe", and then every one of those people is called twice.
A retried create here leaves an empty draft instead. It holds nothing, so there is nothing to launch by mistake. [Attach](/api-reference/runs/attach-run-contacts) is idempotent by construction, so the second request is safe to repeat too.
This is why there is no `Idempotency-Key` header on this API. The one endpoint that would
have needed it does not need it once create is empty.
## The agent must be a custom agent
A run is the batch form of a [custom agent's](/api-reference/agents/create-agent#custom-agents) conversation, so the hiring focuses (`SCREENING`, `OUTREACH`, `LANGUAGE_TEST`, `GENERIC`) cannot back one. A non-custom agent is a `422`.
The agent must also be in your own company. One that is not returns `404` rather than `403` — the API does not confirm that a resource you cannot reach exists.
## Basic Usage
```javascript theme={null}
const run = await fetch("https://api.instaview.sk/runs", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INSTAVIEW_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "Q3 product follow-ups", agentId: "agent-uuid" }),
signal: AbortSignal.timeout(10_000),
});
if (!run.ok) {
throw new Error(`Creating the run failed: ${run.status} ${run.statusText}`);
}
```
The company comes from your API key, never from the body. An ATS key names the child company with the `companyId` **query parameter**; a `companyId` in the body is rejected outright rather than quietly ignored, so a caller who thought they were targeting another company finds out.
## Response
The new run, in the same shape [Get Run](/api-reference/runs/get-run) returns — with an all-zero aggregate, because it has produced nothing yet:
```json theme={null}
{
"id": "789e0123-e45b-67d8-a901-234567890123",
"name": "Q3 product follow-ups",
"agentId": "456e7890-e12b-34d5-a678-901234567890",
"status": "DRAFT",
"progress": {
"total": 0,
"queued": 0,
"inProgress": 0,
"completed": 0,
"failed": 0,
"cancelled": 0,
"unreachable": 0,
"completion": 0
},
"createdAt": "2026-08-29T09:00:00Z",
"updatedAt": "2026-08-29T09:00:00Z"
}
```
## Error Scenarios
* **404 Not Found**: the agent does not exist, or belongs to another company
* **422 Unprocessable Entity**: the agent is not a custom agent
* **400 Bad Request**: the body carries a field this endpoint does not accept, `companyId` included
* **403 Forbidden**: the API key does not hold `write:runs`
## Related Resources
Add the people this run will call
Fan the draft out into conversations
How a run batches conversations
Build the custom agent a run needs
# Delete Run
Source: https://docs.instaview.sk/api-reference/runs/delete-run
DELETE /runs/{id}
Soft-deletes a run that is no longer active. Allowed from DRAFT, COMPLETED and CANCELLED; SCHEDULED, RUNNING and PAUSED return 422 - cancel it first, because a delete must not silently terminate live phone calls. The conversations the run produced survive as separate resources, still readable with their runId intact. Deleting an already-deleted run is a 404, not an error state.
Soft-deletes a run that is no longer active.
## Only when the run is inert
| Status | Delete |
| - | - |
| `DRAFT`, `COMPLETED`, `CANCELLED` | Allowed |
| `SCHEDULED`, `RUNNING`, `PAUSED` | `422` — `"Cancel the flow run before deleting it"` |
An active run has queued calls that a delete would orphan. The alternative — letting `DELETE` cascade a cancel — was deliberately rejected: it makes an HTTP verb **silently terminate live phone calls**, which is not something a delete should do by implication. [Cancel](/api-reference/runs/cancel-run) first; that is the operation whose job is stopping the dialling.
## What happens to everything else
| | |
| - | - |
| **Conversations the run produced** | **Survive.** They are separate resources, still readable at `/conversations/{id}` with `runId` populated. |
| **Webhooks** | Unaffected. `conversation.*` events belong to the conversation, not the run. |
| **Reads of the run** | `GET /runs/{id}` returns `404`. |
| **Deleting it again** | `404`, not an error state to handle. |
Deleting a run removes the grouping, not the history.
## Error Scenarios
* **404 Not Found**: the run does not exist, belongs to another company, or is already deleted
* **422 Unprocessable Entity**: the run is still active
* **403 Forbidden**: the API key does not hold `delete:runs`
## Related Resources
Make an active run inert first
Read the results before you delete
# Detach Contact
Source: https://docs.instaview.sk/api-reference/runs/detach-run-contact
DELETE /runs/{id}/contacts/{contactId}
Removes a contact from a run that has not launched. Draft only: after launch the contact's conversation exists and may already have been called, so removing the link would be a half-truth. Cancel the run instead.
Removes a contact from a run that has not launched.
## Draft only
Detach works while the run is a `DRAFT` and returns `422` after that.
The reason is that launching mints a conversation per contact, and that conversation may already have been called. Removing the link afterwards would be a half-truth: the contact would vanish from the run while the record of their call, its recording and its analysis stayed behind, still carrying the run's id. [Cancel](/api-reference/runs/cancel-run) the run instead — that stops the dialling and leaves an honest record of what happened.
## Basic Usage
```
DELETE /runs/789e0123-.../contacts/contact-uuid
```
```json theme={null}
{ "totalContacts": 9 }
```
Detaching a contact that is not on the run is a `404`.
## Error Scenarios
* **404 Not Found**: the run does not exist, belongs to another company, or the contact is not attached to it
* **422 Unprocessable Entity**: the run has already launched
* **403 Forbidden**: the API key does not hold `write:runs`
## Related Resources
Add contacts to the run
Stop a run that has already launched
# Get Run
Source: https://docs.instaview.sk/api-reference/runs/get-run
GET /runs/{id}
Returns one run with its lifecycle status and aggregate progress, ensuring it belongs to the API key's company. A run batches conversations rather than replacing them: it mints one conversation per contact, and `progress` counts those. For the per-contact detail behind the aggregate, use `GET /runs/{id}/conversations`.
Retrieves a single run by ID, with its lifecycle status and aggregate progress.
## Overview
A run is one agent dialling a list of contacts. It does not replace the conversation, it batches it: launching a run mints **one conversation per contact**, and the call attempts hang off that conversation exactly as they do for a conversation you created on its own.
This endpoint returns the roll-up. For the per-contact detail behind it, use [List Run Conversations](/api-reference/runs/list-run-conversations).
Runs are writable over the API: [create](/api-reference/runs/create-run) one,
[attach contacts](/api-reference/runs/attach-run-contacts) and
[launch](/api-reference/runs/launch-run) it now or up to 30 days out. This endpoint and its
conversations list are how you monitor it afterwards.
## Use Cases
* **Monitor a batch**: poll one endpoint for the state of hundreds of calls
* **Correlate**: match a `conversation.*` webhook back to the batch it came from, through the `runId` in its payload
* **Report**: show your users how far a campaign has got without holding call state yourself
## Response Data
```json theme={null}
{
"id": "789e0123-e45b-67d8-a901-234567890123",
"name": "Q3 product follow-ups",
"agentId": "456e7890-e12b-34d5-a678-901234567890",
"status": "RUNNING",
"scheduledAt": null,
"scheduleError": null,
"progress": {
"total": 42,
"queued": 30,
"inProgress": 3,
"completed": 8,
"failed": 1,
"cancelled": 0,
"unreachable": 0,
"completion": 0.21
},
"createdAt": "2026-08-20T09:00:00Z",
"updatedAt": "2026-08-20T11:42:00Z"
}
```
Reading the buckets:
* Every conversation lands in **exactly one** bucket, so the six counts sum to `total`, and `total` is the run's real conversation count. A `DRAFT` run has produced none yet, so every count is `0`.
* `inProgress` counts calls that are **ringing or connected right now**. That is not the same as the conversation's stored `status`, which stays `SCHEDULED` while a call is being placed — such a call is moved out of `queued` rather than added on top, which is why the buckets still sum.
* `unreachable` is its own bucket rather than part of `failed`. Nothing malfunctioned: the retry budget was spent without the contact ever picking up.
* `completion` is the fraction of conversations in a terminal state — `completed + failed + cancelled + unreachable` over `total` — between `0` and `1`. It is `0` on a run that has produced nothing.
## Scheduled runs
`scheduledAt` is when a `SCHEDULED` run will start dialling, and `null` on every run that launched immediately.
`scheduleError` is why a scheduled run did **not** start at its time. A scheduled launch is re-admitted against billing when it fires, and a refusal then has no request to answer — so it is reported here instead. The run stays `SCHEDULED` and is retried, which means resolving the cause is enough to make it go; there is nothing to re-book.
This field 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 if you are not reading
`scheduleError` it will sit there indefinitely with nobody watching.
## Status
| Status | What it means |
| - | - |
| `DRAFT` | Contacts may be attached; nothing has been dialled |
| `SCHEDULED` | Booked for `scheduledAt`; nothing dialled until then |
| `RUNNING` | Dispatching, or waiting on calls it has dispatched |
| `PAUSED` | Stopped, reversibly. Nothing further is dialled until you resume |
| `COMPLETED` | Every conversation has reached a terminal state |
| `CANCELLED` | Stopped for good. Nothing further is dispatched |
`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. Do not treat `COMPLETED` as a signal to stop polling unless you also know nothing more
will be attached.
## Polling
```javascript theme={null}
async function waitForRun(runId) {
for (;;) {
const response = await fetch(`https://api.instaview.sk/runs/${runId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
// Without a deadline a stalled request never settles.
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`Reading the run failed: ${response.status} ${response.statusText}`);
}
const run = await response.json();
if (run.progress.completion === 1) return run;
await new Promise((resolve) => setTimeout(resolve, 30_000));
}
}
```
Polling is the fallback, not the recommendation. Each conversation the run produces fires the ordinary [`conversation.*` webhooks](/guides/webhooks), and every one of those payloads carries `runId` — so a handler attributes the call to this run without polling, and without reading the conversation back.
## Company Isolation
You can only read runs belonging to your own API key's company. A run in another company returns `404 Not Found`, the same as one that does not exist — the API does not confirm that a resource you cannot read exists.
## Error Scenarios
* **404 Not Found**: the run does not exist, has been deleted, or belongs to a different company
* **403 Forbidden**: the API key does not hold `read:runs`
## Related Resources
How a run batches conversations
The per-contact detail behind the aggregate
What each conversation in a run carries
Be told about each call instead of polling
# Launch Run
Source: https://docs.instaview.sk/api-reference/runs/launch-run
POST /runs/{id}/launch
Fans the draft out into one conversation per contact and moves it DRAFT to RUNNING. Queued, not dialled: a 2xx means the conversations are on the dispatch queue, and each contact's outcome arrives as a conversation.* webhook rather than in this response. All-or-nothing - the whole batch is admitted against billing first, and a run the company cannot afford is refused whole and stays a draft. Send scheduledAt to start it later instead, at most 30 days ahead: the run becomes SCHEDULED, dispatches nothing, and is admitted against billing again when it fires.
Fans a draft run out into one conversation per contact, now or at a time you choose.
## Starting it later
Send `scheduledAt` and the run is booked instead of dispatched. It moves to `SCHEDULED`, queues nothing, and starts on its own when the time arrives.
```javascript theme={null}
POST /runs/{id}/launch { "scheduledAt": "2026-09-15T09:00:00Z" }
{ "status": "SCHEDULED", "affectedCalls": 0 }
```
`affectedCalls` is `0` because nothing was queued. That is the honest count, not an omission.
Up to **30 days ahead** — the same ceiling `scheduleTime` has always had on a single conversation, enforced by the same rule so one call and a batch of them cannot disagree about how far ahead you may plan. Further out, or not a valid ISO 8601 date-time, is a `400` and the run stays a `DRAFT`.
A scheduled run behaves like any other draft until it fires: attach more contacts, or `cancel` it to call the whole thing off. Cancelling clears the appointment, so a cancelled run never starts.
Contacts attached before the run fires are **held, not dialled** — [Attach Contacts](/api-reference/runs/attach-run-contacts) returns `queued: 0` and they go out with everyone else at `scheduledAt`. A booked run has not started, so dispatching them on arrival would call those contacts days early.
### What happens at the appointed time
The run is launched by exactly the code path an immediate launch takes — the same admission, the same fan-out — so a run that starts on Monday morning is indistinguishable from one somebody launched by hand on Monday morning. It may begin up to a minute late; a scheduled start is an appointment, not a deadline.
**Scheduling is not a reservation.** The batch is admitted against your billing when you schedule
it *and again when it fires*, and it is the second one that decides. Nothing is held in between,
so a balance that has moved can leave a booked run unable to start.
A run refused at its time **stays `SCHEDULED` and is retried**, with the reason in
`scheduleError` on [Get Run](/api-reference/runs/get-run). Topping up is enough to make it go;
there is nothing to re-book. Poll `scheduleError`, or a run you booked for a Monday can sit there
unstarted with nobody watching.
## Queued, not dialled
A `2xx` from this endpoint means the conversations exist and are **on the dispatch queue**. It does not mean anybody has been called.
The provider is called later by the dispatch worker, outside your request. So:
* **per-contact outcomes do not come back in this response.** They arrive as [`conversation.*` webhooks](/guides/webhooks), and each payload carries `runId` so you can attribute it to this run without a lookup
* **a `2xx` is not a promise that every call connects.** Contacts who never answer end up `UNREACHABLE`, which [Get Run](/api-reference/runs/get-run) counts in its own bucket
```json theme={null}
{ "status": "RUNNING", "affectedCalls": 42 }
```
`affectedCalls` is how many conversations were queued, not how many calls were placed.
## All of it, or none of it
The whole contact list is admitted against your billing **before any of it is dispatched**. A run you cannot afford in full is refused whole with a [`402`](/guides/error-handling#billing-errors), 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: you cannot un-call the contacts that already went out.
The `402` carries a `billing` object with what the batch needed and what was available, so you can size a smaller batch or work out the top-up.
Admission is a **preflight, not a reservation**. Nothing is held, so two different runs
launched at the same moment can both pass, and your balance can move between admission and
the dial. What protects the money is the per-call reservation made before each individual
call, which fails closed. What admission buys you is being refused **up front** instead of
discovering the problem one failed call at a time.
## Launching twice
`DRAFT` is the only status a run can be launched from, so a second launch is a `422` rather than a second fan-out — including when two launches arrive at the same instant. The run is locked for the duration, so concurrent launches cannot both pass the check.
## A run with no contacts
`422`. There is nothing to dial, and a `RUNNING` run with no conversations would report a meaningless aggregate.
## Error Scenarios
* **400 Bad Request**: `scheduledAt` is not a valid ISO 8601 date-time, or is more than 30 days ahead
* **402 Payment Required**: the company cannot afford the batch. Nothing dispatched, run stays a draft. A scheduled run is admitted at schedule time too, so an unaffordable run is refused before it is booked rather than at its start time
* **404 Not Found**: the run does not exist, or belongs to another company
* **422 Unprocessable Entity**: the run is not a draft, has no contacts, or its agent can no longer back a dispatch
* **403 Forbidden**: the API key does not hold `write:runs`
## Related Resources
Watch the aggregate as it dials
Stop it without losing the work
Per-contact outcomes as they happen
Reading the 402 body
# List Run Conversations
Source: https://docs.instaview.sk/api-reference/runs/list-run-conversations
GET /runs/{id}/conversations
Pages through the conversations a run has produced — one per contact it has dispatched, in the same shape `GET /conversations` returns. A run that has not been launched has none yet. Requires `read:runs` and `read:conversations`.
Pages through the conversations one run has produced.
## Overview
A run mints one conversation per contact it dispatches. This endpoint is the per-contact drill-down behind the aggregate on [Get Run](/api-reference/runs/get-run): each row is a full conversation resource, in exactly the shape [List Conversations](/api-reference/conversations/list-conversations) returns — status, call attempts, and the analysis or analytics of the call.
A run that has not been launched has no conversations yet, and answers with an empty page rather than a `404`.
This endpoint needs **both** `read:runs` and `read:conversations`, because its rows are
conversation resources rather than a run-shaped summary. `read:interviews` satisfies the second
— see [Scopes and Permissions](/guides/scopes-and-permissions).
## Use Cases
* **Drill into a batch**: see which contacts were reached and which were not
* **Collect results**: read every analysis or analytics object a run produced, one page at a time
* **Triage**: find the failed and unreachable contacts to follow up by hand
## Basic Usage
```
GET /runs/789e0123-e45b-67d8-a901-234567890123/conversations
```
## With Pagination
```javascript theme={null}
// Walk the whole run, a page at a time
async function* runConversations(runId) {
for (let page = 1; ; page++) {
const response = await fetch(
`https://api.instaview.sk/runs/${runId}/conversations?page=${page}&limit=100`,
{
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(10_000),
},
);
if (!response.ok) {
throw new Error(`Reading the run's conversations failed: ${response.status}`);
}
const body = await response.json();
yield* body.data;
if (page >= body.totalPages) return;
}
}
```
`limit` is capped at 100. See the [Pagination Guide](/guides/pagination) for the page object's fields.
## Reading the Results
Each row is the ordinary conversation resource, so everything the conversation endpoints document applies here:
* `status` is the conversation's own status, not the run's
* `analysis` carries the recruiting pipeline's output, `analytics` whatever a [custom agent's](/api-reference/agents/create-agent#custom-agents) `analyticsConfig` asked for. A custom agent typically produces `analytics` and no `analysis`
* `runId` is this run's id on every row, which is what lets you attribute a conversation to a run when you receive it any other way
Analysis and analytics are only present once a call has completed and its after-call jobs have
run. While the run is dispatching, most rows carry neither.
## Company Isolation
You can only read runs belonging to your own API key's company. A run in another company returns `404 Not Found`, the same as one that does not exist.
## Error Scenarios
* **404 Not Found**: the run does not exist, has been deleted, or belongs to a different company
* **403 Forbidden**: the API key does not hold both `read:runs` and `read:conversations` (`read:interviews` satisfies the second)
## Related Resources
The run's status and aggregate progress
One conversation in full, with its transcript
How a run batches conversations
How pages work across the API
# Pause Run
Source: https://docs.instaview.sk/api-reference/runs/pause-run
POST /runs/{id}/pause
Stops the run dialling. Calls already queued are withdrawn, and anything the dispatcher had already picked up is refused before it is placed. The work is held rather than discarded: resume puts back exactly what pause held, and a contact already reached is not called again.
Stops a running run from dialling, reversibly.
## Pause is a real stop
* **Nothing further is dialled**, including calls already queued when you paused. They are withdrawn from the dispatch queue, and anything the dispatcher had already picked up is refused at the call itself.
* **The work is held, not discarded.** The conversations stay in place. [Resume](/api-reference/runs/resume-run) puts back exactly what pause held.
* **A contact already reached is not called again.** Resume restores only the conversations that were never dialled.
* **A contact [attached](/api-reference/runs/attach-run-contacts) while paused is not called either.** They join the held work and go out on resume.
```json theme={null}
{ "status": "PAUSED", "affectedCalls": 30 }
```
`affectedCalls` is how many queued calls were withdrawn.
## Only from RUNNING
Pausing a draft, a completed run or a cancelled one is a `422`. A draft has nothing queued to stop.
## Error Scenarios
* **404 Not Found**: the run does not exist, or belongs to another company
* **422 Unprocessable Entity**: the run is not running
* **403 Forbidden**: the API key does not hold `write:runs`
## Related Resources
Put the held calls back
Stop it for good instead
# Resume Run
Source: https://docs.instaview.sk/api-reference/runs/resume-run
POST /runs/{id}/resume
Puts the run's held calls back on the dispatch queue and returns it to RUNNING.
Puts a paused run's held calls back on the dispatch queue.
## What comes back
Exactly what [pause](/api-reference/runs/pause-run) held, plus anything attached while the run was paused. Nothing is dropped, and **nobody is called twice** — a contact already reached during the running window has a call behind them and is not re-queued.
```json theme={null}
{ "status": "RUNNING", "affectedCalls": 30 }
```
`affectedCalls` is how many calls were restored.
## Only from PAUSED
Resuming a run that is not paused is a `422`. In particular there is no resume from `CANCELLED`, which is terminal.
## Error Scenarios
* **404 Not Found**: the run does not exist, or belongs to another company
* **422 Unprocessable Entity**: the run is not paused
* **403 Forbidden**: the API key does not hold `write:runs`
## Related Resources
What resume undoes
Watch it dial again
# Get Enrichment Results
Source: https://docs.instaview.sk/api-reference/sourcing/get-enrich-results
GET /sourcing/enrich/{id}/results
Returns the current state of an enrichment run. Responds with 202 and status 'processing' while the run is in progress, or 200 with full per-profile results when it has completed.
Returns the current state of an enrichment run. While the run is in progress the response is `202 Accepted` with `status: "processing"`. Once finished it becomes `200 OK` with per-profile results.
## Overview
Use this endpoint to poll for enrichment results when you do not have a webhook configured. The `id` path parameter is the `requestId` returned by [POST /sourcing/enrich](/api-reference/sourcing/initiate-enrichment).
## Response Examples
### Still Processing (202 Accepted)
```json theme={null}
{
"success": true,
"requestId": "enr_550e8400-e29b-41d4-a716-446655440001",
"status": "processing",
"message": "Enrichment is in progress. Please poll again in a few seconds.",
"createdAt": "2026-05-20T13:21:00.000Z"
}
```
### Completed (200 OK)
```json theme={null}
{
"success": true,
"requestId": "enr_550e8400-e29b-41d4-a716-446655440001",
"status": "completed",
"enrichedCount": 2,
"failureCount": 0,
"creditsConsumed": 2,
"createdAt": "2026-05-20T13:21:00.000Z",
"completedAt": "2026-05-20T13:21:22.000Z",
"data": [
{
"profileId": "prof_a9238f82-a723",
"success": true,
"candidate": {
"profileId": "prof_a9238f82-a723",
"name": "Jane Doe",
"linkedinUrl": "https://www.linkedin.com/in/janedoe-ml",
"emails": ["jane.doe@techcorp.com", "jdoe.infra@gmail.com"],
"headline": "Staff ML Infrastructure Engineer at TechCorp",
"currentCompany": "TechCorp",
"location": "Seattle, WA, USA",
"matchScore": 96,
"skills": ["vLLM", "PyTorch", "Kubernetes", "LLMs", "Vector Databases"],
"detailedExperience": [
{
"role": "Staff ML Infrastructure Engineer",
"company": "TechCorp",
"duration": "2023 - Present",
"highlights": [
"Architected low-latency prompt caching server decreasing LLM inference cost by 42%.",
"Deployed production-level multi-agent workflow system servicing 2M DAU."
]
}
]
}
},
{
"profileId": "prof_e8321c12-b912",
"success": true,
"candidate": {
"profileId": "prof_e8321c12-b912",
"name": "Alex Smith",
"linkedinUrl": "https://www.linkedin.com/in/alexsmith-ai",
"emails": ["alex.smith@automateglobal.com"],
"headline": "Senior AI Systems Engineer at AutomateGlobal",
"currentCompany": "AutomateGlobal",
"location": "Austin, TX, USA",
"matchScore": 88,
"skills": ["LangChain", "Python", "LlamaIndex", "FastAPI"]
}
}
]
}
```
### Completed with Partial Failure (200 OK)
```json theme={null}
{
"success": true,
"requestId": "enr_550e8400-e29b-41d4-a716-446655440001",
"status": "completed",
"enrichedCount": 1,
"failureCount": 1,
"creditsConsumed": 1,
"createdAt": "2026-05-20T13:21:00.000Z",
"completedAt": "2026-05-20T13:21:22.000Z",
"data": [
{
"profileId": "prof_a9238f82-a723",
"success": true,
"candidate": { "..." : "..." }
},
{
"profileId": "prof_unknown-xyz",
"success": false,
"errorCode": "PROFILE_NOT_FOUND",
"errorMessage": "Profile not found in any sourcing session for this company."
}
]
}
```
### Failed (200 OK with `status: "failed"`)
```json theme={null}
{
"success": true,
"requestId": "enr_550e8400-e29b-41d4-a716-446655440001",
"status": "failed",
"errorCode": "INTERNAL_ERROR",
"errorMessage": "An unexpected error occurred during enrichment.",
"createdAt": "2026-05-20T13:21:00.000Z",
"completedAt": "2026-05-20T13:21:05.000Z"
}
```
## Partial Success vs. Run Failure
A `status: "completed"` response can include individual per-profile failures — those profiles have `success: false` in the `data` array and are not billed. A `status: "failed"` response indicates the entire run encountered an unrecoverable error before any profile could be processed.
# Get Sourcing Preview Results
Source: https://docs.instaview.sk/api-reference/sourcing/get-sourcing-preview-results
GET /sourcing/preview/{id}/results
Returns the current state of a candidate sourcing preview run. Responds with 202 and status 'processing' while in progress, or 200 with the full candidate list (or error details) when completed.
Returns the current state of a sourcing preview run. While the run is in progress the response is `202 Accepted` with `status: "processing"`. Once finished it becomes `200 OK` with the candidate preview list (or error details if the run failed).
## Overview
Use this endpoint to poll for preview results when you do not have a webhook configured. The `requestId` is the value returned by [POST /sourcing/preview](/api-reference/sourcing/initiate-sourcing-preview).
## Polling Strategy
Poll at most once every 10 seconds. Exceeding 120 requests per minute on this
endpoint will trigger `429 RATE_LIMIT_EXCEEDED`.
```javascript theme={null}
async function waitForPreviewResults(requestId) {
const url = `https://api.instaview.sk/sourcing/preview/${requestId}/results`;
const headers = { Authorization: `Bearer ${process.env.INSTAVIEW_API_KEY}` };
while (true) {
const res = await fetch(url, { headers });
if (res.status === 429) {
const retryAfter = Number(res.headers.get("Retry-After") ?? 10);
await new Promise((r) => setTimeout(r, retryAfter * 1000));
continue;
}
if (!res.ok)
throw new Error(`Preview polling failed with status ${res.status}`);
const body = await res.json();
if (body.status === "completed") return body.candidates;
if (body.status === "failed")
throw new Error(`Preview failed: ${body.errorMessage}`);
await new Promise((r) => setTimeout(r, 10000));
}
}
```
## Response Examples
### Still Processing (202 Accepted)
```json theme={null}
{
"success": true,
"requestId": "req_prev_9f3b827e-8c31-4bda-a7a2-b2d99d14f4e2",
"status": "processing",
"candidatesSourcedCount": 12,
"message": "The sourcing agent is actively analyzing candidates. Please poll again in 10 seconds.",
"createdAt": "2026-06-11T19:50:45.000Z"
}
```
### Completed (200 OK)
```json theme={null}
{
"success": true,
"requestId": "req_prev_9f3b827e-8c31-4bda-a7a2-b2d99d14f4e2",
"status": "completed",
"foundCount": 20,
"scoredCount": 20,
"createdAt": "2026-06-11T19:50:45.000Z",
"completedAt": "2026-06-11T19:51:02.000Z",
"candidates": [
{
"profileId": "prof_a9238f82-a723",
"fullName": "Jane Doe",
"profileUrl": "https://www.linkedin.com/in/janedoe-ml",
"title": "Staff ML Infrastructure Engineer",
"headline": "Staff ML Infrastructure Engineer at TechCorp",
"location": "Seattle, WA, USA",
"country": "United States",
"companyName": "TechCorp",
"companyUrl": "https://www.linkedin.com/company/techcorp"
}
]
}
```
### Failed (200 OK with `status: "failed"`)
```json theme={null}
{
"success": true,
"requestId": "req_prev_9f3b827e-8c31-4bda-a7a2-b2d99d14f4e2",
"status": "failed",
"errorCode": "PROVIDER_TEMPORARILY_UNAVAILABLE",
"errorMessage": "External sourcing indexing platform returned a 503 after exhausting retries.",
"createdAt": "2026-06-11T19:50:45.000Z",
"completedAt": "2026-06-11T19:51:01.000Z"
}
```
## Multi-Tenant Isolation
If the `requestId` does not belong to the company associated with the authenticating API key, or if the request ID format does not match a preview run, the API returns `404 REQUEST_NOT_FOUND` or `400 BAD_REQUEST`.
# Get Sourcing Results
Source: https://docs.instaview.sk/api-reference/sourcing/get-sourcing-results
GET /sourcing/{id}/results
Returns the current state of a sourcing run. Responds with 202 and status 'processing' while in progress, or 200 with the full ranked candidate list (or error details) when completed.
Returns the current state of a sourcing run. While the run is in progress the response is `202 Accepted` with `status: "processing"`. Once finished it becomes `200 OK` with the full ranked candidate list (or error details if the run failed).
## Overview
Use this endpoint to poll for results when you do not have a webhook endpoint configured. The `requestId` is the value returned by [POST /sourcing](/api-reference/sourcing/initiate-sourcing).
## Polling Strategy
Poll at most once every 5 seconds. Exceeding 120 requests per minute on this endpoint will trigger `429 RATE_LIMIT_EXCEEDED`.
```javascript theme={null}
async function waitForSourcingResults(requestId) {
const url = `https://api.instaview.sk/sourcing/${requestId}/results`;
const headers = { Authorization: `Bearer ${process.env.INSTAVIEW_API_KEY}` };
while (true) {
const res = await fetch(url, { headers });
const body = await res.json();
if (body.status === 'completed') return body.candidates;
if (body.status === 'failed') throw new Error(`Sourcing failed: ${body.errorMessage}`);
await new Promise(r => setTimeout(r, 5000));
}
}
```
## Response Examples
### Still Processing (202 Accepted)
```json theme={null}
{
"success": true,
"requestId": "req_9f3b827e-8c31-4bda-a7a2-b2d99d14f4e2",
"status": "processing",
"candidatesSourcedCount": 8,
"message": "The sourcing agent is actively analyzing candidates. Please poll again in 5 seconds.",
"createdAt": "2026-05-20T13:20:45.000Z"
}
```
### Completed (200 OK)
```json theme={null}
{
"success": true,
"requestId": "req_9f3b827e-8c31-4bda-a7a2-b2d99d14f4e2",
"status": "completed",
"foundCount": 115,
"scoredCount": 20,
"createdAt": "2026-05-20T13:20:45.000Z",
"completedAt": "2026-05-20T13:22:04.000Z",
"candidates": [
{
"profileId": "prof_a9238f82-a723",
"name": "Jane Doe",
"linkedinUrl": "https://www.linkedin.com/in/janedoe-ml",
"matchScore": 96,
"rationale": "Jane has over 6 years of experience leading ML Infrastructure. She has active contributions to vLLM, has fine-tuned LLaMA-based architectures, and is remote-compliant.",
"headline": "Staff ML Infrastructure Engineer at TechCorp",
"skills": ["vLLM", "PyTorch", "Kubernetes", "LLMs", "Vector Databases"],
"location": "Seattle, WA, USA",
"currentCompany": "TechCorp",
"currentRole": "Staff ML Infrastructure Engineer",
"aiFitPoints": [
"Active open-source contributor to vLLM library",
"Experience scaling agent architectures to 2M DAU at TechCorp"
],
"aiRedFlags": [
"Short average job tenure in pre-2022 startup roles"
],
"aiDetailedRationale": [
{
"title": "Staff ML Infrastructure Engineer",
"company": "TechCorp",
"duration": "3.5 years",
"relevanceExplanation": "LLM scaling & low-latency infrastructure, Kubernetes orchestration"
}
],
"detailedExperience": [
{
"role": "Staff ML Infrastructure Engineer",
"company": "TechCorp",
"duration": "2023 - Present",
"highlights": [
"Architected low-latency prompt caching server decreasing LLM inference cost by 42%.",
"Deployed production-level multi-agent workflow system servicing 2M DAU."
]
}
]
}
]
}
```
### Failed (200 OK with `status: "failed"`)
```json theme={null}
{
"success": true,
"requestId": "req_9f3b827e-8c31-4bda-a7a2-b2d99d14f4e2",
"status": "failed",
"errorCode": "PROVIDER_TEMPORARILY_UNAVAILABLE",
"errorMessage": "External sourcing indexing platform returned a 503 after exhausting retries.",
"createdAt": "2026-05-20T13:20:45.000Z",
"completedAt": "2026-05-20T13:21:01.000Z"
}
```
## Multi-Tenant Isolation
If the `requestId` does not belong to the company associated with the authenticating API key, the API returns `404 REQUEST_NOT_FOUND`. This is intentional — it prevents adversarial probing of active execution queues.
# Initiate Enrichment Run
Source: https://docs.instaview.sk/api-reference/sourcing/initiate-enrichment
POST /sourcing/enrich
Submits a batch of candidate profile IDs for deep background enrichment. Returns immediately with a requestId and status 'processing'. Results are delivered via webhook (ENRICH_COMPLETED / ENRICH_FAILED) or polled at GET /sourcing/enrich/{id}/results. Consumes 1 credit per successfully enriched profile.
Submits a batch of candidate profile IDs for deep enrichment. Returns immediately with a `requestId` and `status: "processing"`. Results are delivered via webhook or polled at [GET /sourcing/enrich//results](/api-reference/sourcing/get-enrich-results).
## Overview
Enrichment performs a deep background evaluation of each profile against external indexing platforms, scrapes publicly available portfolios, and attempts to discover **verified email contact addresses**. Each successfully enriched profile consumes **1 credit**.
### Resilience Behaviour
* **Per-profile timeout**: 10 seconds per candidate.
* **Isolated failures**: if a single profile times out or fails, it is isolated and marked as failed. The remaining profiles in the batch continue processing.
* **Billing**: only successfully enriched profiles are billed.
## Billing Pre-Flight
* **Prepaid accounts**: your credit balance must be ≥ the number of requested profile IDs. If insufficient, the API returns `402 Payment Required` before any enrichment starts.
* **Postpaid accounts**: the run starts immediately; credits are recorded and invoiced at end of the billing cycle.
## Webhook Delivery
Pass a `webhookId` referencing a `WebhookConfig` that subscribes to at least one of `ENRICH_COMPLETED` or `ENRICH_FAILED`. The webhook config must belong to the same company as the API key.
## Examples
### Request
```json theme={null}
{
"profileIds": ["prof_a9238f82-a723", "prof_e8321c12-b912"],
"webhookId": "3f9a12c7-44b8-4d32-b71f-e29a7d1e0f5c"
}
```
### Response (202 Accepted)
```json theme={null}
{
"success": true,
"requestId": "enr_550e8400-e29b-41d4-a716-446655440001",
"status": "processing",
"message": "Enrichment run accepted and queued for processing.",
"webhookId": "3f9a12c7-44b8-4d32-b71f-e29a7d1e0f5c",
"createdAt": "2026-05-20T13:21:00.000Z"
}
```
## Error Responses
### 402 — Insufficient Credits (Prepaid)
```json theme={null}
{
"statusCode": 402,
"error": "INSUFFICIENT_FUNDS",
"message": "Insufficient credits. Need 2, have 0."
}
```
### 422 — Webhook Not Subscribed to Enrichment Events
```json theme={null}
{
"statusCode": 422,
"error": "WEBHOOK_EVENT_NOT_SUBSCRIBED",
"message": "Webhook configuration must subscribe to at least one enrich event (ENRICH_COMPLETED or ENRICH_FAILED)."
}
```
# Initiate Sourcing Run
Source: https://docs.instaview.sk/api-reference/sourcing/initiate-sourcing
POST /sourcing
Starts an asynchronous AI-powered candidate sourcing pipeline. Returns immediately with a requestId and status 'processing'. Results are delivered via webhook or by polling GET /sourcing/{id}/results.
Starts an asynchronous AI-powered candidate sourcing pipeline. Returns immediately with a `requestId` and `status: "processing"`.
## Overview
The sourcing agent runs headless — it derives its own Ideal Candidate Profile (ICP) from your job description, compiles a search query, and enriches matching profiles in parallel batches. No clarifying questions are asked; the pipeline runs fully automatically.
Results are delivered via [webhooks](/guides/webhooks) (recommended) or by polling [GET /sourcing//results](/api-reference/sourcing/get-sourcing-results).
## Billing Pre-Flight
Before a sourcing run is accepted, InstaView validates your billing state against the requested `limit`:
* **Prepaid accounts**: your credit balance must be ≥ `limit` credits. If not, the API returns `402 Payment Required` before any processing starts.
* **Postpaid accounts**: the run starts immediately; credits are recorded and invoiced at the end of the billing cycle.
Successfully sourced candidates consume 1 credit each. Profiles that fail the enrichment step are not billed.
## Webhook Delivery
To receive results without polling, pass a `webhookId` referencing a pre-registered `WebhookConfig` that subscribes to at least one of `SOURCING_COMPLETED` or `SOURCING_FAILED`. The webhook config must belong to the same company as the authenticating API key.
See [Setting Up Webhooks](/guides/webhooks) for instructions on registering a webhook config and storing the signing secret securely.
## Examples
### Minimal Request
```json theme={null}
{
"title": "Senior Backend Engineer",
"jobDescription": "We are looking for a Senior Backend Engineer with 5+ years of Node.js and TypeScript experience, strong PostgreSQL skills, and familiarity with AWS."
}
```
### Full Request
```json theme={null}
{
"title": "Senior Staff ML Engineer",
"jobDescription": "We are seeking a Staff Machine Learning Engineer to design and scale our agentic coding infrastructure. Experience with prompt engineering pipelines, LLM fine-tuning, vector search, and latency optimization is highly required.",
"location": "Remote, USA",
"query": "Prioritize candidates with active contributions to major LLM libraries or frameworks like LangChain, LlamaIndex, or vLLM.",
"limit": 15,
"webhookId": "3f9a12c7-44b8-4d32-b71f-e29a7d1e0f5c"
}
```
### Response (202 Accepted)
```json theme={null}
{
"success": true,
"requestId": "req_9f3b827e-8c31-4bda-a7a2-b2d99d14f4e2",
"status": "processing",
"message": "Sourcing run accepted and queued for processing.",
"webhookId": "3f9a12c7-44b8-4d32-b71f-e29a7d1e0f5c",
"createdAt": "2026-05-20T13:20:45.000Z"
}
```
## Error Responses
### 402 — Insufficient Credits (Prepaid)
```json theme={null}
{
"statusCode": 402,
"error": "INSUFFICIENT_FUNDS",
"message": "Insufficient credits. Need 15, have 3."
}
```
### 403 — Webhook Ownership Mismatch
```json theme={null}
{
"statusCode": 403,
"error": "WEBHOOK_COMPANY_MISMATCH",
"message": "The provided webhookId belongs to a different company."
}
```
### 422 — Webhook Not Subscribed to Sourcing Events
```json theme={null}
{
"statusCode": 422,
"error": "WEBHOOK_EVENT_NOT_SUBSCRIBED",
"message": "Webhook configuration must subscribe to at least one sourcing event (SOURCING_COMPLETED or SOURCING_FAILED)."
}
```
# Initiate Sourcing Preview Run
Source: https://docs.instaview.sk/api-reference/sourcing/initiate-sourcing-preview
POST /sourcing/preview
Starts an asynchronous candidate sourcing preview run. Returns immediately with a requestId and status 'processing'. Results are delivered via webhook (SOURCING_COMPLETED / SOURCING_FAILED) or by polling GET /sourcing/preview/{id}/results.
Starts an asynchronous candidate sourcing preview pipeline. Returns immediately with a `requestId` and `status: "processing"`.
## Overview
The preview endpoint allows you to rapidly run candidate searches and obtain preview results. Under the hood, it queries the underlying provider's search preview API.
### Key Differences from Standard Sourcing
* **No Deep Enrichment**: Preview results do not include deep profile enrichment (like contact details, emails, work history, fit points, or red flags).
* **Fast Execution**: Responses are prepared quickly (usually in a few milliseconds to seconds).
* **Stripped-down Candidate DTO**: Candidates in the results only contain 9 basic fields (listed below).
* **Credit Cost**: Candidate previews charge 1 credit (or 0.4 minutes) per preview page successfully queried (where each page contains up to 20 candidates).
### Candidate Limits and Internal Pagination
The preview API automatically handles pagination internally based on the number of candidates you request:
* Use the optional `limit` parameter to specify the desired candidate count (between `1` and `100`, default: `20`).
* The system automatically polls the underlying provider's preview pages sequentially (up to 5 pages of 20 results each) to satisfy your requested limit.
* Previews are charged per page queried (1 credit / 0.4 minutes per page). For example, requesting a limit of 40 candidates queries up to 2 pages, costing up to 2 credits.
Results are delivered via [webhooks](/guides/webhooks) (recommended) or by polling [GET /sourcing/preview//results](/api-reference/sourcing/get-sourcing-preview-results).
## Billing and Pre-Flight
Candidate previews charge 1 credit (or 0.4 minutes) per preview page successfully queried.
At initialization, a pre-flight balance check is performed for the maximum possible cost based on the requested `limit` (computed as `Math.ceil(limit / 20) * 1 credit`). If the company does not have sufficient credits or minutes, the request is rejected with a `402 Payment Required` status.
During execution, if the provider returns fewer pages than requested (e.g. because results are exhausted), only the pages successfully queried are charged, and any unused credits from the initial hold are automatically released.
## Webhook Delivery
To receive results without polling, pass a `webhookId` referencing a pre-registered `WebhookConfig` that subscribes to at least one of `SOURCING_COMPLETED` or `SOURCING_FAILED`. The webhook config must belong to the same company as the authenticating API key.
When the preview run completes, a `SOURCING_COMPLETED` webhook event is dispatched. The candidates array in the webhook payload will contain preview-formatted candidates containing only the 9 basic fields.
See [Setting Up Webhooks](/guides/webhooks) for instructions on registering a
webhook config and storing the signing secret securely.
## Examples
### Minimal Request
```json theme={null}
{
"title": "Senior Backend Engineer",
"jobDescription": "We are looking for a Senior Backend Engineer with 5+ years of Node.js and TypeScript experience, strong PostgreSQL skills, and familiarity with AWS."
}
```
### Full Request with Candidate Limit
```json theme={null}
{
"title": "Senior Staff ML Engineer",
"jobDescription": "We are seeking a Staff Machine Learning Engineer to design and scale our agentic coding infrastructure. Experience with prompt engineering pipelines, LLM fine-tuning, vector search, and latency optimization is highly required.",
"location": "Remote, USA",
"query": "Prioritize candidates with active contributions to major LLM libraries or frameworks like LangChain, LlamaIndex, or vLLM.",
"webhookId": "3f9a12c7-44b8-4d32-b71f-e29a7d1e0f5c",
"limit": 50
}
```
### Response (202 Accepted)
```json theme={null}
{
"success": true,
"requestId": "req_prev_9f3b827e-8c31-4bda-a7a2-b2d99d14f4e2",
"status": "processing",
"message": "Sourcing preview run accepted and queued for processing.",
"webhookId": "3f9a12c7-44b8-4d32-b71f-e29a7d1e0f5c",
"createdAt": "2026-06-11T19:50:45.000Z"
}
```
## Preview Candidate Fields
The `candidates` array returned for preview requests (both from the polling endpoint and webhook) is stripped down to exactly the following 9 fields:
* `profileId` (Opaque identifier, MD5 hash of LinkedIn profile URL)
* `fullName`
* `profileUrl`
* `title`
* `headline`
* `location`
* `country`
* `companyName`
* `companyUrl`
# Patch Sourcing Run
Source: https://docs.instaview.sk/api-reference/sourcing/patch-sourcing
PATCH /sourcing/{id}
Patches the reasoning context and/or candidate limit of an existing sourcing session and triggers a new background run. Returns 409 if a run is already in progress. At least one of query or limit must be provided.
Refines an existing sourcing session by patching the reasoning context and/or requesting additional candidates. Triggers a new background run and returns `202 Accepted`.
## Overview
After a sourcing run completes you can iterate on it — tighten or relax criteria, prioritize different signals, or harvest more candidates from the same discovered pool. The agent incorporates the new `query` as a context patch before evaluating additional profiles.
**Sequential Execution Constraint**: A PATCH request is only accepted once the current run has a `status` of `"completed"` or `"failed"`. If the run is still `"processing"`, the API returns `409 Conflict`.
## Request Body
At least one of `query` or `limit` must be provided. Both fields may be combined in a single request.
| Field | Type | Required | Description |
| - | - | - | - |
| `query` | `string` | No (one required) | Natural language instruction to patch into the session context. Max 500 chars. |
| `limit` | `number` | No (one required) | Number of **additional** candidates to evaluate (default: 10, max: 50). Profiles already scored in this session are excluded. |
## Examples
### Prompt Refinement Only
```json theme={null}
{
"query": "Relax experience requirement from 8 years to 5 years, but prioritize candidates with strong open-source GitHub portfolios."
}
```
### Request Additional Candidates Only
```json theme={null}
{
"limit": 15
}
```
### Combined Refinement + Additional Candidates
```json theme={null}
{
"query": "Focus on startup founders with Kubernetes orchestration experience at Series A/B companies.",
"limit": 5
}
```
The agent applies the `query` as an updated context patch first, then evaluates `5` additional profiles against the refined criteria — all in a single asynchronous reasoning pass.
## Response (202 Accepted)
```json theme={null}
{
"success": true,
"requestId": "req_9f3b827e-8c31-4bda-a7a2-b2d99d14f4e2",
"status": "processing",
"message": "Sourcing re-run accepted and queued for processing.",
"updatedAt": "2026-05-20T13:21:12.000Z"
}
```
## Error Responses
### 400 — Both Fields Omitted
```json theme={null}
{
"statusCode": 400,
"error": "INVALID_PATCH_PAYLOAD",
"message": "Validation failed: You must provide at least one of 'query' or 'limit'."
}
```
### 409 — Run Still In Progress
```json theme={null}
{
"statusCode": 409,
"error": "RUN_IN_PROGRESS",
"message": "Sourcing agent run 'req_9f3b827e-…' is currently in progress. You can only patch the run context after the current execution finishes."
}
```
# Render HTML Candidate Cards
Source: https://docs.instaview.sk/api-reference/sourcing/render-html
POST /sourcing/render-html
Generates self-contained HTML/CSS candidate profile cards for up to 10 profile IDs. Synchronous — returns immediately with rendered card snippets. Supports 'dark' (default) and 'light' themes.
Generates self-contained, responsive HTML/CSS profile cards for up to 10 candidate profile IDs. This endpoint is **synchronous** — it returns immediately with rendered card snippets.
## Overview
The rendered HTML cards are ready to embed directly into your ATS, internal recruitment dashboards, or email templates. Each card includes:
* Candidate name (gradient-styled heading)
* Current role and company
* AI match score badge
* InstaView rationale summary
* Primary skills list
* LinkedIn profile link button
No external CSS or JavaScript dependencies are required — all styling is applied inline.
## Themes
| Theme | Description |
| - | - |
| `"dark"` | Dark slate background (`#0f172a`) — **default** |
| `"light"` | Clean white background (`#ffffff`) |
## Examples
### Request
```json theme={null}
{
"profileIds": ["prof_a9238f82-a723", "prof_e8321c12-b912"],
"theme": "dark"
}
```
### Response (200 OK)
```json theme={null}
{
"success": true,
"renderedCount": 2,
"data": [
{
"profileId": "prof_a9238f82-a723",
"success": true,
"name": "Jane Doe",
"htmlContent": "\u2026
"
},
{
"profileId": "prof_e8321c12-b912",
"success": true,
"name": "Alex Smith",
"htmlContent": "\u2026
"
}
]
}
```
### Partial Failure (200 OK)
If a profile ID is not found for the authenticating company, it is returned with `success: false` — the rest of the batch is still rendered:
```json theme={null}
{
"success": true,
"renderedCount": 1,
"data": [
{
"profileId": "prof_a9238f82-a723",
"success": true,
"name": "Jane Doe",
"htmlContent": "\u2026
"
},
{
"profileId": "prof_unknown-xyz",
"success": false,
"errorCode": "PROFILE_NOT_FOUND"
}
]
}
```
## Embedding the Card
The `htmlContent` string can be inserted directly into your HTML via `innerHTML` or server-side template rendering:
```javascript theme={null}
const res = await fetch('https://api.instaview.sk/sourcing/render-html', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.INSTAVIEW_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ profileIds: ['prof_a9238f82-a723'], theme: 'light' }),
});
const { data } = await res.json();
document.getElementById('candidate-container').innerHTML = data
.filter(d => d.success)
.map(d => d.htmlContent)
.join('');
```
Always sanitize third-party content before inserting it into the DOM if your CSP or security policy restricts inline styles. The HTML is self-contained and uses only inline CSS — no scripts are included.
# Generate Audio
Source: https://docs.instaview.sk/api-reference/voice/generate-audio
POST /voice
Converts text to speech using AI-powered voices. Returns an audio stream in MP3 format.
Converts text to speech using AI-powered voices. Returns an audio stream in MP3 format that can be played, downloaded, or integrated into applications.
## Overview
The Text-to-Speech API allows you to generate natural-sounding speech from text in multiple languages and voices. This endpoint is useful for creating voice content, accessibility features, voice assistants, and more.
## Use Cases
* **Voice Assistants**: Generate spoken responses for chatbots and virtual assistants
* **Accessibility**: Create audio versions of text content for visually impaired users
* **Content Creation**: Produce voiceovers for videos, podcasts, or presentations
* **E-Learning**: Generate audio narration for educational materials
* **IVR Systems**: Create dynamic voice prompts for phone systems
## Basic Usage
```javascript theme={null}
{
"text": "Hello, welcome to InstaView. We're excited to help you run AI-powered calls.",
"voice": "ALEX",
"language": "en"
}
```
## Voice Options
The API supports 12 different voices with various characteristics:
| Voice Name | Gender | Best For |
| - | - | - |
| **ALEX** | Male | Professional, clear delivery |
| **PETER** | Male | Warm, conversational tone |
| **MIRIAM** | Female | Professional, authoritative |
| **SUE** | Female | Friendly, approachable |
| **VIERA** | Female | Clear, neutral tone |
| **CASANDRA** | Female | Warm, engaging |
| **SILVIA** | Female | Professional, polished |
| **MICHAEL** | Male | Deep, authoritative |
| **LUKE** | Male | Energetic, dynamic |
| **EMMA** | Female | Clear, friendly |
| **SARAH** | Female | Warm, professional |
| **EVA** | Female | Neutral, versatile |
## Language Support
The API supports 33 languages. Use the appropriate language code for your text:
| Language | Code | Language | Code | Language | Code |
| - | - | - | - | - | - |
| English | `en` | Japanese | `ja` | Chinese | `zh` |
| German | `de` | Hindi | `hi` | French | `fr` |
| Korean | `ko` | Portuguese | `pt` | Italian | `it` |
| Spanish | `es` | Indonesian | `id` | Dutch | `nl` |
| Turkish | `tr` | Filipino | `fil` | Polish | `pl` |
| Swedish | `sv` | Bulgarian | `bg` | Romanian | `ro` |
| Arabic | `ar` | Czech | `cs` | Greek | `el` |
| Finnish | `fi` | Croatian | `hr` | Malay | `ms` |
| Slovak | `sk` | Danish | `da` | Tamil | `ta` |
| Ukrainian | `uk` | Russian | `ru` | Hungarian | `hu` |
| Norwegian | `no` | Vietnamese | `vi` | | |
## Response Format
The endpoint returns an audio stream with `Content-Type: audio/mpeg`. You can:
* **Stream directly**: Play the audio in real-time
* **Save to file**: Download and store the MP3 file
* **Integrate**: Use in applications, websites, or phone systems
### Example: Saving Audio (Node.js)
```javascript theme={null}
const fs = require('fs');
const response = await fetch('https://api.instaview.sk/v1/public/voice', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: 'Hello, this is a test.',
voice: 'SARAH',
language: 'en'
})
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const buffer = await response.arrayBuffer();
fs.writeFileSync('output.mp3', Buffer.from(buffer));
```
### Example: Playing Audio (Browser)
```javascript theme={null}
const response = await fetch('https://api.instaview.sk/v1/public/voice', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: 'Hello, this is a test.',
voice: 'EMMA',
language: 'en'
})
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const blob = await response.blob();
const audioUrl = URL.createObjectURL(blob);
const audio = new Audio(audioUrl);
audio.play();
```
### Example: Python
```python theme={null}
import requests
response = requests.post(
'https://api.instaview.sk/v1/public/voice',
headers={
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json'
},
json={
'text': 'Hello, this is a test.',
'voice': 'MICHAEL',
'language': 'en'
}
)
response.raise_for_status()
with open('output.mp3', 'wb') as f:
f.write(response.content)
```
## Text Limitations
The `text` field has a maximum length of **5,000 characters**. For longer content, split the text into multiple requests.
## Authentication & Scopes
This endpoint requires the **VOICE** scope. Ensure your API key has this scope enabled before making requests.
## Error Handling
Common errors you may encounter:
* **400 Bad Request**: Invalid voice name or language code
* **401 Unauthorized**: Missing or invalid API key
* **403 Forbidden**: API key lacks VOICE scope
* **413 Payload Too Large**: Text exceeds 5,000 characters
* **429 Too Many Requests**: Rate limit exceeded
## Rate Limiting
This endpoint is subject to your API key's rate limits. Monitor the rate limit headers in the response:
* `X-RateLimit-Limit`: Maximum requests allowed
* `X-RateLimit-Remaining`: Requests remaining in current window
* `X-RateLimit-Reset`: Time when the rate limit resets
## Related Resources
Learn about API key management and scopes
Understand rate limits and best practices
Handle API errors gracefully
Learn about API key scopes
# Create Webhook
Source: https://docs.instaview.sk/api-reference/webhooks/create-webhook
POST /webhooks
Creates a new webhook configuration for receiving event notifications.
## Overview
Create a webhook to receive real-time HTTP notifications when events occur in your InstaView account. The signing secret is returned only once during creation—store it securely.
## Authentication
Bearer token with `write:webhooks` scope
## Request Body
The HTTPS endpoint URL to receive webhook notifications. Maximum 2048 characters.
Array of event type strings to subscribe to:
* `"ANALYSIS_COMPLETED"` - Analysis finished successfully
* `"ANALYSIS_FAILED"` - Analysis processing failed
* `"PING"` - Test event for connectivity
* `"CONVERSATION_COMPLETED"` - The call finished and analysis is available
* `"CONVERSATION_FAILED"` - A technical failure prevented the call
* `"CONVERSATION_STARTED"` - A call attempt began
* `"CONVERSATION_RESCHEDULED"` - A follow-up call attempt has been scheduled
* `"CONVERSATION_CANCELLED"` - The conversation ended without completing
* `"SOURCING_COMPLETED"` - Sourcing run finished with scored candidates
* `"SOURCING_FAILED"` - Sourcing run terminated with an error
* `"ENRICH_COMPLETED"` - Enrichment run completed
* `"ENRICH_FAILED"` - Enrichment run terminated with an error
The five conversation events also accept their legacy spellings —
`"INTERVIEW_COMPLETED"` and siblings — which register exactly the same subscription. See
[Event name vocabulary](/guides/webhooks#event-name-vocabulary).
Human-readable name for the webhook. Maximum 255 characters.
Description of what this webhook is used for.
Company ID to associate this webhook with. If omitted, webhook will receive events for all companies accessible by the API key. Useful for ATS integrations managing multiple companies with different subdomains.
Custom headers to include in webhook requests. Maximum 50 headers.
Each header object:
* `name` (string, required): Header name (1-255 characters)
* `value` (string, required): Header value (1-4096 characters)
## Response
Returns the created webhook configuration with the signing secret.
The `signingSecret` is only returned once during creation. Store it securely
immediately. If you lose it, you must delete and recreate the webhook.
Unique identifier for the webhook
HMAC signing secret for verifying webhook signatures (hex-encoded, 64 characters)
The endpoint URL
Array of subscribed event type labels
Whether the webhook is active (always `true` for new webhooks)
## Example Request
```bash cURL theme={null}
curl -X POST https://api.instaview.sk/webhooks \
-H "Authorization: Bearer sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/webhooks/instaview",
"name": "Production Webhook",
"description": "Receives conversation completion notifications",
"events": ["CONVERSATION_COMPLETED", "ANALYSIS_COMPLETED"],
"headers": [
{ "name": "Authorization", "value": "Bearer your-internal-token" }
]
}'
```
```javascript Node.js theme={null}
const response = await fetch('https://api.instaview.sk/webhooks', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.INSTAVIEW_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: 'https://api.example.com/webhooks/instaview',
name: 'Production Webhook',
description: 'Receives conversation completion notifications',
events: ["CONVERSATION_COMPLETED", "ANALYSIS_COMPLETED"],
headers: [
{ name: 'Authorization', value: 'Bearer your-internal-token' }
]
})
});
const data = await response.json();
// IMPORTANT: Store this securely - only shown once!
console.log('Signing secret:', data.signingSecret);
```
```python Python theme={null}
response = requests.post(
'https://api.instaview.sk/webhooks',
headers={
'Authorization': f'Bearer {os.environ["INSTAVIEW_API_KEY"]}',
'Content-Type': 'application/json'
},
json={
'url': 'https://api.example.com/webhooks/instaview',
'name': 'Production Webhook',
'description': 'Receives conversation completion notifications',
'events': ["CONVERSATION_COMPLETED", "ANALYSIS_COMPLETED"],
'headers': [
{'name': 'Authorization', 'value': 'Bearer your-internal-token'}
]
}
)
data = response.json()
# IMPORTANT: Store this securely - only shown once!
print(f'Signing secret: {data["signingSecret"]}')
```
## Example Response
```json theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"apiKeyId": "api-key-uuid",
"companyId": "company-uuid",
"url": "https://api.example.com/webhooks/instaview",
"name": "Production Webhook",
"description": "Receives conversation completion notifications",
"headers": [
{
"name": "Authorization",
"isMasked": true
}
],
"events": [
"conversation.completed",
"analysis.completed"
],
"vocabulary": "NEUTRAL",
"isActive": true,
"maxRetries": 3,
"timeoutMs": 30000,
"consecutiveFailures": 0,
"circuitOpenedAt": null,
"signingSecret": "<64-character hex string — returned only here>",
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
```
## Error Responses
```json theme={null}
{
"statusCode": 400,
"message": "Validation failed",
"errors": ["url must be a valid URL"],
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4"
}
```
```json theme={null}
{
"statusCode": 403,
"message": "Insufficient permissions. Required scopes: write:webhooks",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
## Best Practices
1. **Store the signing secret immediately** - It's only returned once
2. **Include the ping event** for testing - You can remove it later
3. **Use HTTPS endpoints** - HTTP is only allowed for localhost
4. **Add authentication headers** - Protect your webhook endpoint
## Related
Learn about signature verification and payloads
Test your webhook endpoint
# Delete Webhook
Source: https://docs.instaview.sk/api-reference/webhooks/delete-webhook
DELETE /webhooks/{id}
Deletes a webhook configuration.
## Overview
Permanently delete a webhook configuration. Pending deliveries will not be sent after deletion.
This action is irreversible. All pending deliveries for this webhook will be
cancelled and not sent.
## Authentication
Bearer token with `write:webhooks` scope
## Path Parameters
The webhook configuration ID (UUID v4)
## Response
Returns `204 No Content` on successful deletion.
## Example Request
```bash cURL theme={null}
curl -X DELETE https://api.instaview.sk/webhooks/550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer sk_your_key_here"
```
```javascript Node.js theme={null}
const webhookId = '550e8400-e29b-41d4-a716-446655440000';
const response = await fetch(
`https://api.instaview.sk/webhooks/${webhookId}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${process.env.INSTAVIEW_API_KEY}`
}
}
);
if (response.status === 204) {
console.log('Webhook deleted successfully');
}
```
```python Python theme={null}
webhook_id = '550e8400-e29b-41d4-a716-446655440000'
response = requests.delete(
f'https://api.instaview.sk/webhooks/{webhook_id}',
headers={
'Authorization': f'Bearer {os.environ["INSTAVIEW_API_KEY"]}'
}
)
if response.status_code == 204:
print('Webhook deleted successfully')
```
## Error Responses
```json theme={null}
{
"statusCode": 404,
"message": "Webhook configuration not found",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
```json theme={null}
{
"statusCode": 403,
"message": "Access denied to this webhook configuration",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
## Best Practices
Before deleting a webhook:
1. **Disable first** - Set `isActive: false` to test impact
2. **Monitor for issues** - Ensure no critical integrations depend on it
3. **Update dependent systems** - Notify systems expecting webhook deliveries
## Alternative: Disable Instead of Delete
If you might need the webhook later, consider disabling it instead:
```javascript theme={null}
// Disable instead of delete
await fetch(`https://api.instaview.sk/webhooks/${webhookId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
isActive: false
})
});
```
## Related
List all webhook configurations
Create a new webhook
# Get Webhook
Source: https://docs.instaview.sk/api-reference/webhooks/get-webhook
GET /webhooks/{id}
Returns a single webhook configuration by ID.
## Overview
Retrieve detailed information about a specific webhook configuration, including its health status, subscribed events, and configuration details.
## Authentication
Bearer token with `read:webhooks` scope
## Path Parameters
The webhook configuration ID (UUID v4)
## Response
Unique identifier for the webhook
The API key ID this webhook belongs to
The endpoint URL receiving webhook notifications
Human-readable name for the webhook
Description of the webhook's purpose
Custom headers configured for this webhook. Sensitive header values are masked.
Array of subscribed event type labels, spelled in the webhook's own `vocabulary`
Which spelling this webhook receives for the five conversation events: `NEUTRAL` or `LEGACY`.
`NEUTRAL` is the default for webhooks created since the option existed, and delivers
`conversation.completed`, `conversation.failed`, `conversation.started`,
`conversation.rescheduled` and `conversation.cancelled`. `LEGACY` — every webhook created
before it — delivers the same five as `interview.*`. Nothing else differs: the same
subscriptions fire on the same occasions, and `ANALYSIS_*`, `SOURCING_*`, `ENRICH_*` and
`ping` carry one spelling either way. See [Event name
vocabulary](/guides/webhooks#event-name-vocabulary).
Whether the webhook is currently active
Maximum retry attempts for failed deliveries
Request timeout in milliseconds
Current count of consecutive delivery failures
ISO 8601 timestamp when circuit breaker was triggered (null if closed)
ISO 8601 timestamp when webhook was created
ISO 8601 timestamp when webhook was last updated
## Example Request
```bash cURL theme={null}
curl -X GET https://api.instaview.sk/webhooks/550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer sk_your_key_here"
```
```javascript Node.js theme={null}
const webhookId = '550e8400-e29b-41d4-a716-446655440000';
const response = await fetch(
`https://api.instaview.sk/webhooks/${webhookId}`,
{
headers: {
'Authorization': `Bearer ${process.env.INSTAVIEW_API_KEY}`
}
}
);
const data = await response.json();
console.log(`Webhook: ${data.name}`);
console.log(`Events: ${data.events.join(', ')}`);
console.log(`Active: ${data.isActive}`);
```
```python Python theme={null}
webhook_id = '550e8400-e29b-41d4-a716-446655440000'
response = requests.get(
f'https://api.instaview.sk/webhooks/{webhook_id}',
headers={
'Authorization': f'Bearer {os.environ["INSTAVIEW_API_KEY"]}'
}
)
data = response.json()
print(f'Webhook: {data["name"]}')
print(f'Events: {", ".join(data["events"])}')
print(f'Active: {data["isActive"]}')
```
## Example Response
```json theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"apiKeyId": "api-key-uuid",
"url": "https://api.example.com/webhooks/instaview",
"name": "Production Webhook",
"description": "Receives conversation completion notifications",
"headers": [
{
"name": "Authorization",
"isMasked": true
},
{
"name": "X-Custom-Header",
"isMasked": false
}
],
"events": [
"conversation.completed",
"analysis.completed"
],
"vocabulary": "NEUTRAL",
"isActive": true,
"maxRetries": 3,
"timeoutMs": 30000,
"consecutiveFailures": 0,
"circuitOpenedAt": null,
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
```
## Error Responses
```json theme={null}
{
"statusCode": 404,
"message": "Webhook configuration not found",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
```json theme={null}
{
"statusCode": 403,
"message": "Access denied to this webhook configuration",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
## Related
List all webhook configurations
Update webhook configuration
# List Webhooks
Source: https://docs.instaview.sk/api-reference/webhooks/list-webhooks
GET /webhooks
Lists all webhook configurations for your API key.
## Overview
The list webhooks endpoint returns all webhook configurations associated with your API key. Use this to monitor webhook health, check subscription status, and manage your webhook configurations.
## Authentication
Bearer token with `read:webhooks` scope
## Query Parameters
Filter webhooks by company ID. If provided, returns only webhooks associated with that specific company (excludes global webhooks). If omitted, returns all webhooks including global ones. Useful for ATS integrations managing multiple companies.
## Response
Array of webhook configuration objects
Total number of webhook configurations
### Webhook Configuration Object
Unique identifier for the webhook
The API key ID this webhook belongs to
Company ID this webhook is associated with. Null if webhook receives events for all companies (global webhook).
The endpoint URL receiving webhook notifications
Human-readable name for the webhook
Description of the webhook's purpose
Custom headers configured for this webhook. Sensitive header values are
masked.
Array of subscribed event type labels (e.g., "conversation.completed"), spelled in the webhook's own `vocabulary`
Which spelling this webhook receives for the five conversation events: `NEUTRAL` or `LEGACY`.
`NEUTRAL` is the default for webhooks created since the option existed, and delivers
`conversation.completed`, `conversation.failed`, `conversation.started`,
`conversation.rescheduled` and `conversation.cancelled`. `LEGACY` — every webhook created
before it — delivers the same five as `interview.*`. Nothing else differs: the same
subscriptions fire on the same occasions, and `ANALYSIS_*`, `SOURCING_*`, `ENRICH_*` and
`ping` carry one spelling either way. See [Event name
vocabulary](/guides/webhooks#event-name-vocabulary).
Whether the webhook is currently active
Maximum retry attempts for failed deliveries
Request timeout in milliseconds
Current count of consecutive delivery failures
ISO 8601 timestamp when circuit breaker was triggered (null if closed)
ISO 8601 timestamp when webhook was created
ISO 8601 timestamp when webhook was last updated
## Example Request
```bash cURL theme={null}
curl -X GET "https://api.instaview.sk/webhooks?companyId=company-uuid" \
-H "Authorization: Bearer sk_your_key_here"
```
```javascript Node.js theme={null}
const response = await fetch("https://api.instaview.sk/webhooks", {
headers: {
Authorization: `Bearer ${process.env.INSTAVIEW_API_KEY}`,
},
});
const page = await response.json();
console.log(`Found ${page.total} webhooks`);
```
```python Python theme={null}
response = requests.get(
'https://api.instaview.sk/webhooks',
headers={
'Authorization': f'Bearer {os.environ["INSTAVIEW_API_KEY"]}'
}
)
page = response.json()
print(f'Found {page["total"]} webhooks')
```
## Example Response
```json theme={null}
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"apiKeyId": "api-key-uuid",
"companyId": "company-uuid",
"url": "https://api.example.com/webhooks/instaview",
"name": "Production Webhook",
"description": "Receives conversation completion notifications",
"headers": [
{
"name": "Authorization",
"isMasked": true
},
{
"name": "X-Custom-Header",
"isMasked": false
}
],
"events": [
"conversation.completed",
"analysis.completed"
],
"vocabulary": "NEUTRAL",
"isActive": true,
"maxRetries": 3,
"timeoutMs": 30000,
"consecutiveFailures": 0,
"circuitOpenedAt": null,
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
],
"total": 1
}
```
## Monitoring Webhook Health
Check webhook health by examining `consecutiveFailures` and `circuitOpenedAt`:
```javascript theme={null}
async function checkWebhookHealth() {
const response = await fetch("https://api.instaview.sk/webhooks", {
headers: { Authorization: `Bearer ${apiKey}` },
});
const { data } = await response.json();
const unhealthy = data.filter(
(w) => w.consecutiveFailures > 0 || w.circuitOpenedAt
);
for (const webhook of unhealthy) {
console.warn(`Webhook ${webhook.name || webhook.id}:`);
console.warn(` Failures: ${webhook.consecutiveFailures}`);
console.warn(` Circuit Open: ${webhook.circuitOpenedAt || "No"}`);
}
return unhealthy;
}
```
## Related
Create a new webhook configuration
Learn about webhook events and payloads
# Reset Circuit Breaker
Source: https://docs.instaview.sk/api-reference/webhooks/reset-circuit
POST /webhooks/{id}/reset-circuit
Resets the circuit breaker for a webhook that was automatically disabled due to consecutive failures.
## Overview
When a webhook experiences too many consecutive delivery failures, the circuit breaker automatically opens to prevent further attempts. This endpoint allows you to reset the circuit breaker and re-enable the webhook after you've fixed the underlying issue.
## Authentication
Bearer token with `write:webhooks` scope
## Path Parameters
The webhook configuration ID (UUID v4)
## Response
Returns the updated webhook configuration with reset circuit breaker state.
Reset to 0
Reset to null
Re-enabled (true)
## Example Request
```bash cURL theme={null}
curl -X POST https://api.instaview.sk/webhooks/550e8400-e29b-41d4-a716-446655440000/reset-circuit \
-H "Authorization: Bearer sk_your_key_here"
```
```javascript Node.js theme={null}
const webhookId = '550e8400-e29b-41d4-a716-446655440000';
const response = await fetch(
`https://api.instaview.sk/webhooks/${webhookId}/reset-circuit`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.INSTAVIEW_API_KEY}`
}
}
);
const data = await response.json();
console.log(`Circuit reset. Active: ${data.isActive}`);
console.log(`Consecutive failures: ${data.consecutiveFailures}`);
```
```python Python theme={null}
webhook_id = '550e8400-e29b-41d4-a716-446655440000'
response = requests.post(
f'https://api.instaview.sk/webhooks/{webhook_id}/reset-circuit',
headers={
'Authorization': f'Bearer {os.environ["INSTAVIEW_API_KEY"]}'
}
)
data = response.json()
print(f'Circuit reset. Active: {data["isActive"]}')
print(f'Consecutive failures: {data["consecutiveFailures"]}')
```
## Example Response
```json theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"apiKeyId": "api-key-uuid",
"url": "https://api.example.com/webhooks/instaview",
"name": "Production Webhook",
"description": "Receives conversation completion notifications",
"headers": [
{
"name": "Authorization",
"isMasked": true
}
],
"events": [
"conversation.completed",
"analysis.completed"
],
"vocabulary": "NEUTRAL",
"isActive": true,
"maxRetries": 3,
"timeoutMs": 30000,
"consecutiveFailures": 0,
"circuitOpenedAt": null,
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-16T09:00:00Z"
}
```
## Circuit Breaker Workflow
Check `consecutiveFailures` and `circuitOpenedAt` to confirm circuit is open
Resolve the underlying problem (endpoint availability, authentication, etc.)
Use the [test endpoint](/api-reference/webhooks/test-webhook) to verify connectivity
Call this endpoint to re-enable the webhook
Watch for new failures after reset
## Recommended Reset Procedure
```javascript theme={null}
async function safeResetCircuit(webhookId) {
// 1. Check current state
const { data: webhook } = await getWebhook(webhookId);
if (!webhook.circuitOpenedAt) {
console.log('Circuit is not open, no reset needed');
return webhook;
}
console.log(`Circuit has been open since: ${webhook.circuitOpenedAt}`);
console.log(`Consecutive failures: ${webhook.consecutiveFailures}`);
// 2. Ensure webhook has ping event for testing
if (!webhook.events.includes('ping')) {
console.log('Adding ping event for testing...');
await updateWebhook(webhookId, {
events: [...webhook.events, 'ping']
});
}
// 3. Test connectivity first
console.log('Testing webhook connectivity...');
const testResult = await testWebhook(webhookId);
if (!testResult.success) {
console.error('Webhook test failed:', testResult.message);
console.error('Fix the issue before resetting circuit');
return null;
}
console.log('Webhook test successful');
// 4. Reset circuit
console.log('Resetting circuit breaker...');
const { data: resetWebhook } = await resetCircuit(webhookId);
console.log(`Circuit reset. Webhook is now active: ${resetWebhook.isActive}`);
return resetWebhook;
}
```
## Common Causes of Circuit Breaker Trips
* Server crashed or was redeployed
* DNS resolution failed
* Network connectivity issues
**Fix**: Ensure your endpoint is up and accessible
* API token expired
* Custom header value changed
* Authentication endpoint down
**Fix**: Update the webhook's custom headers with valid credentials
* Endpoint taking too long to respond
* Long-running synchronous processing
* Network latency
**Fix**: Acknowledge webhooks immediately and process asynchronously
* Application errors (5xx)
* Validation failures (4xx)
* Incorrect response format
**Fix**: Check your application logs and fix the errors
## Error Responses
```json theme={null}
{
"statusCode": 404,
"message": "Webhook configuration not found",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
```json theme={null}
{
"statusCode": 403,
"message": "Access denied to this webhook configuration",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
## Related
Test connectivity before resetting
Check circuit breaker status
# Test Webhook
Source: https://docs.instaview.sk/api-reference/webhooks/test-webhook
POST /webhooks/{id}/test
Sends a test ping event to verify webhook connectivity and signature validation.
## Overview
Use this endpoint to verify that your webhook endpoint is correctly configured and can receive and validate webhook payloads. The test sends a `ping` event to your endpoint.
The webhook must be subscribed to the `ping` event for testing to work.
## Authentication
Bearer token with `write:webhooks` scope
## Path Parameters
The webhook configuration ID (UUID v4)
## Response
Whether the test was successful
Unique delivery ID for tracking (only on success)
HTTP status code returned by your endpoint (only on success)
Request duration in milliseconds (only on success)
Human-readable result message
## Example Request
```bash cURL theme={null}
curl -X POST https://api.instaview.sk/webhooks/550e8400-e29b-41d4-a716-446655440000/test \
-H "Authorization: Bearer sk_your_key_here"
```
```javascript Node.js theme={null}
const webhookId = '550e8400-e29b-41d4-a716-446655440000';
const response = await fetch(
`https://api.instaview.sk/webhooks/${webhookId}/test`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.INSTAVIEW_API_KEY}`
}
}
);
const data = await response.json();
if (data.success) {
console.log('✅ Webhook test successful');
console.log(` Response time: ${data.durationMs}ms`);
console.log(` HTTP status: ${data.httpStatus}`);
} else {
console.error('❌ Webhook test failed:', data.message);
}
```
```python Python theme={null}
webhook_id = '550e8400-e29b-41d4-a716-446655440000'
response = requests.post(
f'https://api.instaview.sk/webhooks/{webhook_id}/test',
headers={
'Authorization': f'Bearer {os.environ["INSTAVIEW_API_KEY"]}'
}
)
data = response.json()
if data['success']:
print('✅ Webhook test successful')
print(f' Response time: {data["durationMs"]}ms')
print(f' HTTP status: {data["httpStatus"]}')
else:
print(f'❌ Webhook test failed: {data["message"]}')
```
## Example Responses
### Successful Test
```json theme={null}
{
"success": true,
"deliveryId": "delivery-uuid",
"httpStatus": 200,
"durationMs": 156,
"message": "Webhook test successful!"
}
```
### Failed Test
```json theme={null}
{
"success": false,
"message": "Webhook test failed: Connection timeout"
}
```
### Missing Ping Event Subscription
```json theme={null}
{
"success": false,
"message": "Webhook is not subscribed to ping events. Add the ping event to the webhook configuration."
}
```
### Webhook Disabled
```json theme={null}
{
"success": false,
"message": "No delivery was created. This may indicate the webhook is disabled or circuit breaker is open."
}
```
## Test Payload
Your endpoint will receive this payload during testing:
```json theme={null}
{
"event": "ping",
"timestamp": "2024-01-20T14:30:00.000Z",
"deliveryId": "delivery-uuid",
"data": {
"message": "Test ping from InstaView webhook system"
}
}
```
## Verifying Signature in Test
Use the test to verify your signature validation is working correctly:
```javascript theme={null}
// Configure Express to preserve raw body for signature verification
app.post('/webhooks/instaview',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-webhook-signature'];
const payload = req.body.toString(); // Now this is the raw buffer
if (!verifySignature(payload, signature, process.env.WEBHOOK_SECRET)) {
console.error('Signature verification failed');
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(payload);
if (event.event === 'ping') {
console.log('Ping test received successfully');
console.log('Message:', event.data.message);
}
res.status(200).send('OK');
}
);
```
## Troubleshooting
* Ensure your endpoint is publicly accessible
* Check if your firewall allows incoming connections
* Verify your endpoint responds within 30 seconds
* Verify the webhook URL is correct
* Check if your server is running
* Ensure the port is open and accessible
* Check your custom Authorization header value
* Verify your endpoint's authentication logic
* Ensure signature verification is working correctly
Add the ping event to your webhook:
```javascript theme={null}
await fetch(`/webhooks/${webhookId}`, {
method: 'PATCH',
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
events: ["ping", "conversation.completed", "analysis.completed"]
})
});
```
## Related
Learn about signature verification
Add ping event subscription
# Update Webhook
Source: https://docs.instaview.sk/api-reference/webhooks/update-webhook
PATCH /webhooks/{id}
Updates an existing webhook configuration.
## Overview
Update webhook settings including URL, subscribed events, custom headers, and active status. Only provided fields are updated; omitted fields remain unchanged.
## Authentication
Bearer token with `write:webhooks` scope
## Path Parameters
The webhook configuration ID (UUID v4)
## Request Body
All fields are optional. Only provided fields are updated.
The HTTPS endpoint URL to receive webhook notifications. Maximum 2048 characters.
Array of event type strings to subscribe to:
* `"ANALYSIS_COMPLETED"` - Analysis finished successfully
* `"ANALYSIS_FAILED"` - Analysis processing failed
* `"PING"` - Test event for connectivity
* `"CONVERSATION_COMPLETED"` - The call finished and analysis is available
* `"CONVERSATION_FAILED"` - A technical failure prevented the call
* `"CONVERSATION_STARTED"` - A call attempt began
* `"CONVERSATION_RESCHEDULED"` - A follow-up call attempt has been scheduled
* `"CONVERSATION_CANCELLED"` - The conversation ended without completing
* `"SOURCING_COMPLETED"` - Sourcing run finished with scored candidates
* `"SOURCING_FAILED"` - Sourcing run terminated with an error
* `"ENRICH_COMPLETED"` - Enrichment run completed
* `"ENRICH_FAILED"` - Enrichment run terminated with an error
The five conversation events also accept their legacy spellings —
`"INTERVIEW_COMPLETED"` and siblings — which register exactly the same subscription. See
[Event name vocabulary](/guides/webhooks#event-name-vocabulary).
Human-readable name for the webhook. Maximum 255 characters.
Description of what this webhook is used for.
Custom headers to include in webhook requests. Maximum 50 headers.
This replaces all existing headers. Include all headers you want to keep.
Whether the webhook is active. Set to `false` to temporarily disable the webhook.
## Response
Returns the updated webhook configuration.
## Example Request
```bash cURL theme={null}
curl -X PATCH https://api.instaview.sk/webhooks/550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/webhooks/instaview-v2",
"events": ["ANALYSIS_COMPLETED", "ANALYSIS_FAILED", "CONVERSATION_COMPLETED", "CONVERSATION_FAILED"]
}'
```
```javascript Node.js theme={null}
const webhookId = '550e8400-e29b-41d4-a716-446655440000';
const response = await fetch(
`https://api.instaview.sk/webhooks/${webhookId}`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${process.env.INSTAVIEW_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: 'https://api.example.com/webhooks/instaview-v2',
events: ["ANALYSIS_COMPLETED", "ANALYSIS_FAILED", "CONVERSATION_COMPLETED", "CONVERSATION_FAILED"]
})
}
);
const data = await response.json();
console.log(`Webhook updated: ${data.url}`);
```
```python Python theme={null}
webhook_id = '550e8400-e29b-41d4-a716-446655440000'
response = requests.patch(
f'https://api.instaview.sk/webhooks/{webhook_id}',
headers={
'Authorization': f'Bearer {os.environ["INSTAVIEW_API_KEY"]}',
'Content-Type': 'application/json'
},
json={
'url': 'https://api.example.com/webhooks/instaview-v2',
'events': ["ANALYSIS_COMPLETED", "ANALYSIS_FAILED", "CONVERSATION_COMPLETED", "CONVERSATION_FAILED"]
}
)
data = response.json()
print(f'Webhook updated: {data["url"]}')
```
## Example Response
```json theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"apiKeyId": "api-key-uuid",
"url": "https://api.example.com/webhooks/instaview-v2",
"name": "Production Webhook",
"description": "Receives conversation completion notifications",
"headers": [
{
"name": "Authorization",
"isMasked": true
}
],
"events": [
"analysis.completed",
"analysis.failed",
"conversation.completed",
"conversation.failed"
],
"vocabulary": "NEUTRAL",
"isActive": true,
"maxRetries": 3,
"timeoutMs": 30000,
"consecutiveFailures": 0,
"circuitOpenedAt": null,
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T12:00:00Z"
}
```
## Common Update Scenarios
### Disable Webhook Temporarily
```javascript theme={null}
await fetch(`https://api.instaview.sk/webhooks/${webhookId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
isActive: false
})
});
```
### Update Event Subscriptions
```javascript theme={null}
await fetch(`https://api.instaview.sk/webhooks/${webhookId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
events: ["ANALYSIS_COMPLETED", "ANALYSIS_FAILED", "PING", "CONVERSATION_COMPLETED", "CONVERSATION_FAILED", "CONVERSATION_STARTED", "SOURCING_COMPLETED", "SOURCING_FAILED", "ENRICH_COMPLETED", "ENRICH_FAILED"]
})
});
```
### Update Custom Headers
```javascript theme={null}
// Note: This replaces ALL headers
await fetch(`https://api.instaview.sk/webhooks/${webhookId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
headers: [
{ name: 'Authorization', value: 'Bearer new-token' },
{ name: 'X-Environment', value: 'production' }
]
})
});
```
## Error Responses
```json theme={null}
{
"statusCode": 400,
"message": "Validation failed",
"errors": ["url must be a valid URL"],
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4"
}
```
```json theme={null}
{
"statusCode": 404,
"message": "Webhook configuration not found",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
```json theme={null}
{
"statusCode": 403,
"message": "Access denied to this webhook configuration",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
## Related
View webhook configuration details
Reset circuit breaker for failed webhook
# Authentication
Source: https://docs.instaview.sk/authentication
Learn how to authenticate with the InstaView API using API keys
## Overview
The InstaView API uses API keys for authentication. Each API key is associated with a company and has specific scopes that control what actions it can perform.
## API Key Format
InstaView API keys follow this format:
```
sk_1a2b3c4d5e6f7g8h9i0jklmnopqrstuv
```
* **Prefix**: `sk_` indicates a secret key
* **Key**: Random Base62-encoded string (URL-safe, no special characters)
* **Length**: Approximately 46 characters total
**Never expose your API keys!** Keep them secure and never commit them to
version control. Use environment variables or secret management systems.
## Authentication Method
### Bearer Token
Pass your API key in the `Authorization` header with the Bearer scheme:
```bash theme={null}
curl https://api.instaview.sk/jobs \
-H "Authorization: Bearer sk_your_api_key_here"
```
This follows the industry-standard OAuth 2.0 Bearer token format for authentication.
## Creating API Keys
Access your [InstaView dashboard](https://app.instaview.sk)
Go to **Settings** → **API Keys**
Click the **Create API Key** button
* Give it a descriptive name (e.g., "Production Integration") - Select the
required scopes - Optionally set an expiration date - Configure IP allowlist
if needed
Copy the API key immediately - you won't see it again!
## Scopes and Permissions
API keys use scope-based access control. Each resource type has three permission levels:
View and list resources
Create and update resources
Delete resources (soft delete)
### Available Scopes
| Resource | Read Scope | Write Scope | Delete Scope |
| - | - | - | - |
| Jobs | `read:jobs` | `write:jobs` | `delete:jobs` |
| Contacts | `read:contacts` | `write:contacts` | `delete:contacts` |
| Conversations | `read:conversations` | `write:conversations` | `delete:conversations` |
| Runs | `read:runs` | `write:runs` | `delete:runs` |
| Agents | `read:agents` | `write:agents` | `delete:agents` |
| Companies | `read:companies` | `write:companies` | - |
| Billing | `read:billing` | - | - |
Contact and conversation scopes each have a legacy alias — `read:candidates`,
`read:interviews` and so on — which works exactly the same and stays valid on keys that
already carry it. See [Scopes and Permissions](/guides/scopes-and-permissions).
### Scope Examples
```json theme={null}
{
"scopes": [
"read:jobs",
"read:contacts",
"read:conversations"
]
}
```
Perfect for analytics dashboards and reporting tools.
```json theme={null}
{
"scopes": [
"read:jobs",
"write:jobs",
"delete:jobs",
"read:contacts",
"write:contacts",
"delete:contacts",
"read:conversations",
"write:conversations",
"delete:conversations",
"read:agents",
"write:agents",
"delete:agents"
]
}
```
Complete access for primary integrations.
```json theme={null}
{
"scopes": [
"read:contacts",
"write:contacts",
"read:jobs",
"read:conversations"
]
}
```
Focused access for a system that manages contacts.
## Company Isolation
Every API key is scoped to a specific company. This ensures data isolation:
* Keys can only access resources within their company
* Cross-company access is prevented at the API level
* Each company's data is completely isolated
### Regular API Keys
Standard API keys are associated with a single company:
```bash theme={null}
# This key can only access resources for Company A
curl https://api.instaview.sk/jobs \
-H "Authorization: Bearer sk_abc123def456ghi789jkl"
```
### ATS Integration Keys
ATS (Applicant Tracking System) keys have special privileges:
* Can create and manage multiple companies
* Must specify `companyId` query parameter for resource access
* Ideal for multi-tenant integrations
```bash theme={null}
# ATS key accessing Company B's resources
curl https://api.instaview.sk/jobs?companyId=company-b-uuid \
-H "Authorization: Bearer sk_ats123xyz456abc789def"
```
ATS keys can only be created by InstaView administrators. Write to support if
you need ATS integration capabilities.
## Security Best Practices
Never hardcode API keys in your application code.
```bash theme={null}
# .env file
INSTAVIEW_API_KEY=sk_your_key_here
```
```javascript theme={null}
// Node.js
const apiKey = process.env.INSTAVIEW_API_KEY;
```
* Rotate API keys every 90 days - Create a new key before revoking the old one
to avoid downtime - Use the dashboard to manage key lifecycle
Apply the principle of least privilege: - Only grant scopes that are
absolutely necessary - Use read-only keys for analytics and reporting - Create
separate keys for different integrations
Restrict API key usage to specific IP addresses:
```json theme={null}
{
"allowedIPs": [
"192.168.1.100",
"10.0.0.0/8"
]
}
```
Keys will only work from these IP addresses.
* Review API key audit logs regularly - Set up alerts for unusual activity -
Track key usage in the dashboard
Configure automatic key expiration for temporary integrations:
* Set expiration during key creation
* Receive notifications before expiry
* Ideal for contractor access or time-limited projects
## Key Management
### Listing Your Keys
View all API keys in your dashboard:
* Active keys and their scopes
* Last used timestamp
* Creation date
* Expiration date (if set)
### Suspending Keys
Temporarily disable a key without deleting it:
```javascript theme={null}
// Keys can be suspended via the dashboard
// or programmatically through the admin API
```
Suspended keys will return a `401 Unauthorized` error.
### Revoking Keys
Permanently revoke a compromised key:
Revoked keys cannot be restored. Create a new key if needed.
### Audit Logs
All API key operations are logged:
* Key creation and deletion
* Successful authentications
* Failed authentication attempts
* Scope changes
* Suspension and revocation events
## Common Authentication Errors
Invalid or missing API key
```json theme={null}
{
"message": "Invalid API key",
"error": "Unauthorized",
"statusCode": 401
}
```
Note: The API returns a generic error message to prevent information leakage about which keys are valid.
Insufficient scopes for the requested operation
```json theme={null}
{
"message": "Insufficient permissions: Required scope write:jobs not found",
"error": "Forbidden",
"statusCode": 403
}
```
This error occurs when your API key doesn't have the required scope for the operation you're trying to perform.
**Rate Limiting**: Rate limiting is planned for future implementation.
Currently, the API does not enforce rate limits, but this feature will be
added soon. When implemented, you'll receive appropriate HTTP 429 responses
with retry-after headers.
## Next Steps
Deep dive into API key management
Learn about scope-based access control
Understand rate limits and best practices
Handle authentication errors properly
# November 2025
Source: https://docs.instaview.sk/changelog/2025-11
Changelog entries for November 2025
## November 29, 2025
### Added
* **Optional Job Associations**: Added support for candidates without job assignments, enabling candidate pool functionality
* **Multi-Job Assignments**: Added support for multi-job assignment in candidate management
### Changed
* **Candidate Management**: Updated candidate creation and update to support optional job associations
* **Interview Creation**: Enhanced interview creation to support candidate pool functionality
### Removed
* **SetupCompleted Field**: Removed setupCompleted field from API
***
## November 28, 2025
### Changed
* **Bearer Token Authorization**: Unified bearer token authorization across all API endpoints
* **Security**: Patched security vulnerabilities in API documentation
***
## November 27, 2025
### Changed
* **Base URL**: Updated API base URL to `https://api.instaview.sk`
* **Support Links**: Updated support links throughout documentation
***
## November 26, 2025
### Changed
* **Error Messages**: Enhanced error message documentation and responses
# December 2025
Source: https://docs.instaview.sk/changelog/2025-12
Changelog entries for December 2025
## December 16, 2025
### Added
* **Webhooks API**: Full webhook support for real-time event notifications
* Configure webhook endpoints to receive events when interviews complete, analyses finish, or failures occur
* HMAC-SHA256 signature verification for secure payload validation
* Automatic retry with exponential backoff for failed deliveries
* Circuit breaker pattern to protect your endpoints from cascading failures
* Custom headers support with encryption for sensitive authentication data
* Test endpoint for verifying webhook connectivity
* **Test Interview Mode**: Enhanced interview creation with test mode support
* Create test interviews that immediately complete without consuming billing minutes
* Comprehensive mock analysis data automatically generated for screening interviews
* Automatic webhook event delivery (`interview.completed` and `analysis.completed`) for testing webhook handlers
* Perfect for integration testing, development, and webhook handler verification
* Use `isTest: true` flag in interview creation endpoint
* **New Scopes**: Added `read:webhooks` and `write:webhooks` scopes for webhook management
### Documentation
* **Webhooks Guide**: Comprehensive guide covering event types, payloads, signature verification, and best practices
* **Webhooks Resource**: Detailed configuration reference for webhook management
* **API Reference**: Full endpoint documentation for webhook CRUD operations, testing, and circuit breaker reset
* **Test Interview Documentation**: Added comprehensive test mode documentation with examples and mock data structure details
***
## December 14, 2025
### Changed
* **Analysis Output Structure**: Fixed and improved analysis output structure documentation in interview responses
* **Interview Types**: Added comprehensive interview types documentation with detailed explanations
***
## December 10, 2025
### Changed
* **Currency Field Format**: Updated currency field to use ISO 4217 alpha-3 format
* **Response Descriptions**: Enhanced response descriptions and data formats in API responses
* **Data Handling**: Improved data handling examples and documentation
***
## December 8, 2025
### Added
* **Rate Limiting**: Added comprehensive rate limiting functionality, integrated with subscription plans
***
## December 7, 2025
### Changed
* **Interview Validation**: Enhanced interview creation validation documentation, including XOR constraint explanations
* **Error Messages**: Improved error messages in API responses for better understanding
* **Scopes and Permissions**: Updated scopes and permissions guide with latest API key validation logic
***
## December 4, 2025
### Added
* **Transient Jobs and Agents**: Added support for transient jobs and agents in public API
* **Immediate Scheduling**: Added optional immediate scheduling functionality
***
## December 3, 2025
### Added
* **Transient Candidates**: Added support for transient candidates and jobs in interview creation
***
## December 2, 2025
### Changed
* **Job Description**: Made jobDescription field optional in job creation and updates
* **ATS Keys**: Added support for ATS keys to use companyId
***
## December 1, 2025
### Added
* **JobId Field**: Added jobId field to interview creation endpoint
### Changed
* **Validation Constraints**: Added min/max items constraints for jobIds in candidate creation
# January 2026
Source: https://docs.instaview.sk/changelog/2026-01
Changelog entries for January 2026
## January 30, 2026
### Analysis
* **Webhook Payloads**: The `analysis.completed` webhook event now includes full analysis data in the payload, eliminating the need to make a separate API call to fetch results.
## January 26, 2026
### Changed
* **Job Association**: When creating an interview with a `jobId`, candidates who are not already assigned to that job will be automatically assigned to it.
## January 19, 2026
### Documentation
* **Agent Configuration**: Clarified that `duration` for agents is specified in **minutes** (max 180 minutes), addressing user confusion regarding time units.
* **Documentation Fixes**: Corrected example payloads in Quickstart and Resource Guides for:
* **Agents**: Replaced invalid `voiceId` ("curated\_english\_code") with valid enum value ("ALEX").
* **Jobs**: Fixed incorrect casing for `workMode` (should be `HYBRID`, not `hybrid`) and `salary.period` (should be `YEARLY`, not `annual`).
* **Candidates**: Removed non-existent `resumeUrl` field from creation examples and verified `gdprExpiryDate` requirement.
* **Interviews**: Corrected `scheduledAt` to `scheduleTime` in creation examples.
* **Enums**: Aligned "Agent Focus" and "Work Modes" tables with uppercase API enums (`SCREENING`, `REMOTE`, etc.).
* **Duration**: Added specific documentation about the 180-minute limit for agent duration.
* **Examples**: Updated Agent Focus examples to showcase primary use cases (`SCREENING`, `OUTREACH`, `GENERIC`, `LANGUAGE_TEST`).
* **Candidates**: Added "Request Timeouts" best practice example.
* **Jobs**: Fixed malformed location examples to correctly place `workMode` and remove incorrect nesting.
# February 2026
Source: https://docs.instaview.sk/changelog/2026-02
Changelog entries for February 2026
## February 14, 2026
### New Features
* **Create Job from URL**: Added a new `POST /jobs/url` endpoint that extracts structured job data from a job posting URL and optionally creates a job in a single operation. Simply provide a URL, and the API will scrape the page, extract details (title, description, skills, salary, location, etc.), and return the created job. Set `createJob` to `false` to only extract data without persisting a job. Requires the `write:jobs` scope.
## February 1, 2026
### New Features
* **Text-to-Speech API**: Introduced a new `/voice` endpoint for generating audio from text. The API supports 12 different voices and 33 languages, returning audio streams in MP3 format. Requires the `VOICE` API key scope.
# March 2026
Source: https://docs.instaview.sk/changelog/2026-03
Changelog entries for March 2026
## March 28, 2026
### New Features
* **Analysis PDF in API & Webhooks**: The candidate analysis PDF report is now available as a Base64-encoded string via a dedicated endpoint and in webhooks.
* **`GET /interviews/:id/analysis-pdf`** — New endpoint that returns the Base64-encoded PDF report of the candidate analysis. Separated from the main interview resource to keep `GET /interviews/:id` responses lightweight.
* **`analysis.completed` webhook** — Payload now includes `analysisPdfBase64` alongside the existing analysis data. If PDF generation fails, the field is omitted and the webhook is delivered normally with the rest of the data.
* Enables clients to download the PDF report on demand or receive it automatically via webhooks.
## March 10, 2026
### New Features
* **Anonymized CV Text in API**: Candidates now support an optional `cvText` field for plain text CV input.
* When provided via **`POST /candidates`** or **`PATCH /candidates/{id}`**, the text is automatically anonymized to remove personally identifiable information (PII).
* The anonymized version is stored and exposed in the **`anonymizedCvText`** field in API responses (**`GET /candidates`**, **`GET /candidates/{id}`**, etc.).
* This feature enables seamless integration for ATS systems that store CVs as plain text while ensuring data privacy for AI-driven analysis.
## March 6, 2026
### New Features
* **Interview Transcripts in API & Webhooks**: Call attempt transcripts are now exposed in the public API and the `analysis.completed` webhook.
* **`GET /interviews/:id`** — Each `callAttempt` object now includes a `transcript` array of chronologically-ordered segments. Every segment contains the `speaker` (`AI` or `HUMAN`), the transcribed `text`, `startTime` / `endTime` (seconds from recording start), and the detected `language`. Interviews with multiple call attempts each carry their own isolated transcript, preventing segment-order ambiguity.
* **`analysis.completed` webhook** — The specific call attempt that triggered the webhook now includes its `transcript` directly on the call attempt object. Other call attempts in the payload have their `transcript` omitted to keep the payload focused.
* A new `PublicTranscriptSegmentDto` schema has been added to the OpenAPI spec documenting the structure of each segment.
## March 4, 2026
### New Features
* **Candidate Gender Field**: Added an optional `gender` field (`"male"` / `"female"`) to the Candidate resource. When set, it is used as the primary source for gender-aware addressing in interviews (e.g., Slovak/Czech formal titles like "pán"/"pani"). If not provided, gender is automatically detected from the candidate's name. The field can be set during creation, updated later, or cleared by setting it to `null` to revert to auto-detection. Available on `POST /candidates`, `PATCH /candidates/{id}`, and `GET /candidates/{id}` endpoints.
# April 2026
Source: https://docs.instaview.sk/changelog/2026-04
Changelog entries for April 2026
## April 2, 2026
### New Features
* **Job-Optional GENERIC Interviews**: Interviews with a `GENERIC` agent focus can now be created without providing a `jobId`.
* **`POST /interviews`** — When the resolved agent has a `GENERIC` focus, the `jobId` field is no longer required. This allows flexible use cases such as general candidate conversations that are not tied to a specific position.
* For non-GENERIC focus types (`SCREENING`, `OUTREACH`, `LANGUAGE_TEST`, etc.), a `jobId` is still required. If the candidate has exactly one active job assignment and no `jobId` is specified, the system now **auto-resolves** it automatically.
* When a candidate has multiple job assignments and no `jobId` is provided, the request returns a clear error message guiding the caller to specify one.
### Bug Fixes
* **Timezone-Aware Interview Scheduling**: Interview scheduling and rescheduling now correctly respect ISO 8601 timezone offsets.
* When `scheduledAt` (on **`POST /interviews`**) or the reschedule timestamp includes an explicit UTC offset (e.g. `2026-04-02T07:00:00+10:30`), that offset is now used directly for call time interval validation. Previously, the offset was silently discarded and the system fell back to phone-number-based timezone detection or the company default, which could place calls outside the intended local time window.
* Invalid date formats now return a descriptive `400 Bad Request` error including the specific parsing failure reason.
## April 21, 2026
### New Features
* **Owner Onboarding via Email**: ATS partners can now specify a customer owner email when creating or updating companies via the API.
* **`ownerEmail` Support**: Added an optional `ownerEmail` field to `POST /v1/company` and `PATCH /v1/company/:id`.
* **Automated Invitations**: If the provided email corresponds to an existing InstaView user, they are automatically assigned as the `OWNER` of the company. If the user does not exist, the system automatically triggers a **Company Invitation** email to that address.
* **Visibility**: The `ownerEmail` is now returned in company responses in `GET` and `LIST` requests, allowing partners to verify which user is linked to each company.
## April 25, 2026
### New Features
* **Public Call Attempt Management**: Added support for managing individual call attempts for phone interviews via the Public API.
* **`POST /interviews/{id}/call-attempts`** — Manually invoke a new call attempt or schedule a retry for an existing interview.
* **`PATCH /interviews/{id}/call-attempts/{attemptId}`** — Reschedule a pending call attempt to a different time.
* **`DELETE /interviews/{id}/call-attempts/{attemptId}`** — Cancel and delete a scheduled call attempt.
* **Constraints**: These operations are available only for phone-based interviews. Individual call attempt management is blocked for `ONLINE` interviews to maintain consistency.
* **Active Call Protection**: To prevent race conditions, call attempts cannot be rescheduled or deleted once they have moved to the `IN_PROGRESS` state.
## April 27, 2026
### New Features
* **Credits Billing System Support in Public API**: The Public Billing API now reports usage for both legacy minutes-based companies and new credits-based companies.
* **`GET /billing/usage`** response additions:
* `billingSystem` (per company): Either `"minutes"` or `"credits"` — indicates which unit the company is billed in.
* `totalCreditsUsed` (per company and aggregate): Total credits consumed in the selected period. Present for credits-based companies; omitted otherwise.
* `breakdownByCostKey[].creditsUsed`: Per-cost-key credits total. Present only for credits-based companies.
* For minutes-based companies the response shape is unchanged — `totalMinutesUsed` and `breakdownByCostKey[].minutesUsed` continue to be the source of truth.
## April 28, 2026
### New Features
* **Delete Company Endpoint**: Added support for deleting companies via the Public API.
* **`DELETE /company/{id}`** — Permanently deletes a specific company by ID.
* **Access Rules**: Regular API keys can only delete their associated company. ATS integration keys can delete any child company they've created.
* **Cascade Deletion**: This is a destructive operation that will delete all related entities (jobs, candidates, interviews) for the specified company.
# May 2026
Source: https://docs.instaview.sk/changelog/2026-05
Changelog entries for May 2026
## May 30, 2026
### New Features
* **List Phone Numbers — Public API**: A new `GET /phone-numbers` endpoint is available for all API key types, returning all active phone numbers assigned to the company.
* Requires the new **`read:phone_numbers`** scope on the API key.
* Supports standard pagination (`page`, `limit`) and the `companyId` query parameter for ATS partner keys managing multiple child companies.
## May 28, 2026
### Enhancements
* **Public Sourcing API Photo Support**: Exposed candidate profile picture URLs in the public API sourcing and enrichment responses.
* Added a new **`photoUrl`** property to `PublicSourcingCandidateDto` that fetches the profile image or avatar directly from persistent candidate records in the database.
* Populates photo links across search results polling (`GET /sourcing/{id}/results`), asynchronous webhooks (`sourcing.completed`), and profile enrichment results (`GET /sourcing/enrich/{id}/results`).
## May 27, 2026
### New Features
* **Agent Background Sound**: A new `backgroundSound` field is available on agents, controlling the ambient noise played to candidates during calls.
* Accepted values: `"OFFICE"` (default — subtle office noise) and `"OFF"` (silence).
* Configurable via the agent create/update form in the dashboard and via the Public API (`POST /v1/agents`, `PATCH /v1/agents/:id`).
## May 25, 2026
### New Features
* **Public Sourcing & Enrichment API**: A fully programmable, asynchronous candidate sourcing API is now available for ATS partners and direct integrations.
* **`POST /sourcing`** — Initiates a background AI-powered candidate sourcing run. The sourcing agent runs fully automatically, deriving the Ideal Candidate Profile (ICP) directly from the supplied `title`, `jobDescription`, `location`, and optional `query`. Returns immediately with a `requestId` and `status: "processing"`.
* **`PATCH /sourcing/{id}`** — Patches the reasoning context of a completed run and triggers a new asynchronous sourcing pass. Accepts `query` (context refinement) and/or `limit` (additional candidates) in a single request. Returns `409 Conflict` if the previous run is still processing.
* **`GET /sourcing/{id}/results`** — Polls the status and full ranked candidate list for a sourcing run. Returns `202 Accepted` while processing and `200 OK` with the complete result payload on completion or failure.
* **`POST /sourcing/enrich`** — Asynchronously enriches up to 10 candidate profiles per request. Performs deep background evaluation including email discovery. Returns immediately with a `requestId` and `status: "processing"`. Consumes 1 credit per successfully enriched profile; failed profiles are not billed.
* **`GET /sourcing/enrich/{id}/results`** — Polls per-profile enrichment results. Returns `202 Accepted` while processing and `200 OK` with per-profile results (supporting partial success) on completion.
* **`POST /sourcing/render-html`** — Synchronously generates self-contained inline HTML/CSS candidate profile cards for up to 10 profiles. Supports `"dark"` (default) and `"light"` themes. Suitable for direct embedding in ATS dashboards or email templates.
* **New Webhook Event Types**: Four new webhook event types across two new categories are now supported in `WebhookConfig` subscriptions:
* `SOURCING_COMPLETED` / `sourcing.completed` — Dispatched when a sourcing run finishes with a scored candidate list.
* `SOURCING_FAILED` / `sourcing.failed` — Dispatched when a sourcing run terminates with an unrecoverable error.
* `ENRICH_COMPLETED` / `enrich.completed` — Dispatched when an enrichment run completes (partial profile-level failures do not prevent this event from firing).
* `ENRICH_FAILED` / `enrich.failed` — Dispatched when an enrichment run terminates with an unrecoverable error before any profile is processed.
* Pass the `webhookId` of a pre-registered `WebhookConfig` in the sourcing/enrichment request bodies to receive asynchronous delivery. The config must subscribe to at least one event from the relevant category.
* **Sourcing API Key Scopes**: Three scopes gate access to the new sourcing endpoints.
* `sourcing:write` — `POST /sourcing`, `PATCH /sourcing/{id}`
* `sourcing:read` — `GET /sourcing/{id}/results`
* `enrich:write` — `POST /sourcing/enrich`, `GET /sourcing/enrich/{id}/results`, `POST /sourcing/render-html`
* **Execution & Billing Behaviour**: All sourcing and enrichment runs use a fire-and-forget background execution model. External index queries have a 15-second timeout with 2 automatic retries and a circuit-breaker failover. Per-profile enrichment has a 10-second timeout with isolated failure handling — one timed-out profile does not fail the entire batch. All resources (`requestId`s, candidate lists, enrichment results) are strictly bound to the `companyId` of the authenticating API key; cross-tenant access attempts return `404 REQUEST_NOT_FOUND` to prevent resource enumeration. Prepaid credit validation runs before a sourcing or enrichment run starts; postpaid accounts are invoiced at end of billing cycle.
# June 2026
Source: https://docs.instaview.sk/changelog/2026-06
Changelog entries for June 2026
## June 12, 2026
### New Features
* **Candidate Work History Support**: Added full support for exposing and managing candidate work histories via the public API and inline candidate creation flows.
* **`workHistory` Schema**: The array items include `companyName` (required), `candidatePosition` (required), `referenceName` (optional), `referencePhone` (required E.164 phone string for references), `startDate` (optional `yyyy-MM-dd` date), and `endDate` (optional `yyyy-MM-dd` date).
* **API Endpoints**: Supported on candidate creation (`POST /candidates`), update (`PATCH /candidates/{id}`), and retrieval (`GET /candidates` & `GET /candidates/{id}`).
* **Inline Interview Scheduling**: Supported inline `workHistory` mapping on candidate creation nested inside interview scheduling (`POST /interviews`).
* **Robust Internal Mapping**: Refactored logic to map transparently to/from database schemas using a unified helper.
### Bug Fixes
* **Interview Creation Validation**: Added validation on interview creation (`POST /interviews`) to ensure the candidate has required contact details matching the chosen interview type.
* Requires candidate phone number for phone interviews.
* Requires candidate email address for online interviews.
## June 11, 2026
### New Features
* **Sourcing Preview API**: A new high-speed endpoint is now available for generating search previews and candidate listings.
* **`POST /sourcing/preview`** — Initiates a candidate sourcing preview run. This endpoint is designed for speed, returning basic candidate details in seconds instead of calling the fully enriched candidate search.
* **`GET /sourcing/preview/{id}/results`** — A dedicated polling endpoint to fetch the state and results of a preview run. Prevents mixing up standard and preview results.
* **Stripped Candidate DTO**: The returned candidates are simplified to just 9 essential fields (`profileId`, `fullName`, `profileUrl`, `title`, `headline`, `location`, `country`, `companyName`, `companyUrl`) and exclude deep enrichment features (e.g., contact info, emails, fit points, or red flags).
* **Internal Pagination**: Automatically handles pagination internally based on the requested candidate `limit` (between 1 and 100, default: 20). No need to pass page query parameters.
* **Integration with Existing Flows**: The endpoint fits seamlessly into the current asynchronous flows, allowing result retrieval via the new preview results endpoint or webhook delivery using existing `SOURCING_COMPLETED` and `SOURCING_FAILED` events.
* **Page-based Credit Cost**: Sourcing previews charge 1 credit (or 0.4 minutes) per preview page successfully queried (where each page contains up to 20 candidates), with standard pre-flight validation checks run at initialization.
* **Required Scope**: Requires the `sourcing:write` scope for initiating runs and `sourcing:read` scope for polling results.
# July 2026
Source: https://docs.instaview.sk/changelog/2026-07
Changelog entries for July 2026
## July 20, 2026
### New Features
* **Interview Lifecycle Webhooks**: Two new webhook events close long-standing gaps in the interview event stream. Previously, retries and cancellations produced no webhook at all — integrators saw repeated `interview.started` events with no explanation for the gaps between them, and an interview that exhausted its call attempts never emitted a terminal event.
* **`interview.rescheduled`** — Sent when a call attempt does not produce a completed interview and a follow-up attempt has been scheduled. Register with `"INTERVIEW_RESCHEDULED"`.
* **Two-attempt shape**: Because this event sits *between* call attempts, it does not carry a single `callAttemptId`. Instead it splits `previousAttempt` (with `callAttemptId`, mapped `status`, raw `endedReason`, `durationSeconds`, `attemptNumber`) and `nextAttempt` (with `callAttemptId`, `scheduledAt`, `attemptNumber`).
* **`reason`**: A stable vocabulary — `CANDIDATE_REQUEST`, `NO_ANSWER`, `BUSY`, `VOICEMAIL`, `TECHNICAL_FAILURE`, `API_REQUEST`, or `ADMIN`. Branch on this rather than on the raw `previousAttempt.endedReason`, which is diagnostic only. `API_REQUEST` means you moved the attempt via `PATCH /interviews/{id}/call-attempts/{attemptId}`; `ADMIN` means InstaView staff moved it from the internal admin panel.
* **Moves vs. retries**: the `PATCH` reschedule endpoint moves an existing attempt rather than ending one and creating another, so for `reason: "API_REQUEST"` (and `ADMIN`) the same `callAttemptId` appears on both sides of the payload. Every other reason yields two distinct attempts.
* **`candidateRequest`**: Present only when the candidate asked to be called back. Includes their verbatim `rawText` and a `timeResolved` flag — when `false`, the requested time could not be interpreted and a default slot was used instead, meaning the candidate is not expecting a call at `scheduledAt`.
* **`retryCount` / `maxAttempts`**: How close the interview is to exhausting its retry budget. `maxAttempts` is omitted for candidate-requested reschedules, which do not spend that budget.
* **`interview.cancelled`** — Sent when an interview reaches a terminal state without completing. Register with `"INTERVIEW_CANCELLED"`.
* **`reason`**: `MAX_ATTEMPTS_EXCEEDED` (every permitted attempt made without reaching the candidate), `CANDIDATE_NOT_INTERESTED` (declined during the call, with the finer-grained outcome in `reasonDetail`), `USER_REQUEST` (cancelled from the InstaView dashboard), or `ADMIN` (cancelled by InstaView staff from the internal admin panel).
* **No `API_REQUEST` value**: the public API has no interview-level cancel endpoint. `DELETE /interviews/{id}` removes the interview outright and cancels its pending webhook deliveries, so no `interview.cancelled` event is emitted for it.
* Also carries `attemptsMade`, `cancelledBy`, and `lastCallAttemptId` (absent when the interview was cancelled before any call was placed).
* **Event ordering**: Every interview now emits exactly one terminal event — `interview.completed`, `interview.cancelled`, or `interview.failed`. See the [Webhooks guide](/guides/webhooks) for representative sequences.
### Behavior Changes
* **Interviews that exhaust their call attempts are now `CANCELLED`, not `FAILED`**: Reaching the retry ceiling means we stopped trying to reach the candidate, not that the platform malfunctioned, so the interview status now reflects that and stays consistent with the `interview.cancelled` webhook emitted alongside it. Individual call attempts are still recorded as `FAILED`. If you filter or report on `FAILED` interviews, note that this cohort has moved.
### Documentation
* **Webhook event types corrected**: The webhook resource reference previously documented event types as numeric IDs (`events: [3, 0]`). The API has always accepted string constants (`"INTERVIEW_COMPLETED"`); the reference has been corrected throughout.
* **OpenAPI response schema completed**: The `events` enum on webhook responses was missing `sourcing.completed`, `sourcing.failed`, `enrich.completed`, and `enrich.failed`. Generated clients would have rejected valid responses containing those labels.
# August 2026
Source: https://docs.instaview.sk/changelog/2026-08
Changelog entries for August 2026
## 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.
```javascript theme={null}
POST /runs/{id}/launch { "scheduledAt": "2026-09-15T09:00:00Z" }
{ "status": "SCHEDULED", "affectedCalls": 0 }
```
`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:
```javascript theme={null}
POST /runs // an empty draft
POST /runs/{id}/contacts // add contacts (attaching an id twice is a no-op)
POST /runs/{id}/launch // one conversation per contact
POST /runs/{id}/pause | /resume | /cancel // drive it
DELETE /runs/{id}/contacts/{contactId} // remove a contact from a draft
DELETE /runs/{id} // remove an inert run
```
* **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](/guides/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`](/guides/error-handling#billing-errors) 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.
```javascript theme={null}
{ "attached": 8, "skipped": 2, "totalContacts": 10, "queued": 8 }
```
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](/guides/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](/guides/resources/agents).
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.
```text theme={null}
before Guten Tag, hier ist Eva, ein KI-Rekrutierer für Acme. Ich bin ein KI-Sprachassistent.
after Guten Tag, hier ist Eva, ein KI-Rekrutierer für Acme.
```
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:
```javascript theme={null}
GET /runs/{id} // lifecycle status + aggregate progress
GET /runs/{id}/conversations // the per-contact drill-down, paginated
```
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:
```json theme={null}
{
"id": "789e0123-e45b-67d8-a901-234567890123",
"name": "Q3 product follow-ups",
"status": "RUNNING",
"progress": {
"total": 42,
"queued": 30,
"inProgress": 3,
"completed": 8,
"failed": 1,
"cancelled": 0,
"unreachable": 0,
"completion": 0.21
}
}
```
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](/api-reference/runs/get-run), [List Run Conversations](/api-reference/runs/list-run-conversations) and the [Runs guide](/guides/resources/runs).
* **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.
```javascript theme={null}
// GET /conversations/{id}
{ "id": "550e8400-…", "contactId": "…", "agentId": "…", "runId": "789e0123-…" }
```
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**:
```javascript theme={null}
// conversation.completed, and its started / failed / rescheduled / cancelled siblings,
// plus analysis.completed and analysis.failed
{
"event": "conversation.completed",
"data": { "conversationId": "550e8400-…", "contactId": "…", "runId": "789e0123-…", "status": "COMPLETED" }
}
```
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](/guides/webhooks#payload-structure).
* **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](/guides/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](/guides/error-handling#billing-errors) already described as the billing one.
The rule is now clean: **`402` is money, `403` is permissions.**
```javascript theme={null}
{
"statusCode": 402,
"message": "Insufficient minutes available to schedule 10 interviews (50 minutes total). Please purchase additional minutes.",
"error": "PAYMENT_REQUIRED",
"billing": {
"billingSystem": "minutes",
"requiredMinutes": 50,
"availableMinutes": 12,
"suggestedAction": "purchase_minutes"
}
}
```
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`](/api-reference/billing/get-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:
```javascript theme={null}
"IN_PROGRESS" // a call is live right now
"SCHEDULED" // the next attempt is queued and waiting, with scheduledAt set
```
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`](/guides/webhooks#conversation-rescheduled-payload) (`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](/guides/resources/conversations#conversation-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:
```
/api-reference/conversations/create-conversation was /api-reference/interviews/create-interview
/api-reference/contacts/list-contacts was /api-reference/candidates/list-candidates
/guides/resources/conversations was /guides/resources/interviews
/guides/resources/contacts was /guides/resources/candidates
```
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](/api-reference/introduction#resource-names) 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](/guides/error-handling#retrying-a-write-safely) 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](/guides/error-handling#retrying-a-write-safely) 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.
| Deleting | Also deletes |
| - | - |
| A **contact** | Every conversation they had — transcripts, recordings, analyses — and their job assignments |
| A **job** | Every contact whose job it is, and every conversation of theirs |
| An **agent** | Every conversation that agent ran |
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](/guides/error-handling), the resource guides and the webhook reference pages showed a nested envelope with a machine-readable code:
```json theme={null}
{ "error": { "code": "VALIDATION_ERROR", "message": "…", "details": { "…": "…" } } }
```
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](/guides/error-handling#error-handling-patterns) 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:
```javascript theme={null}
{
"analysis": {
"general": {
"overallRating": null,
"scoringIncompleteReason": "model_unavailable",
"strongPoints": ["Strong technical skills"], // unaffected
"status": 3
}
}
}
```
`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](/guides/resources/conversations#when-there-is-no-rating).
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:
```javascript theme={null}
conversation.started // was, and still is, interview.started
conversation.completed
conversation.failed
conversation.rescheduled
conversation.cancelled
```
`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:
```javascript theme={null}
PATCH /webhooks/{id} { "vocabulary": "NEUTRAL" }
```
* **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](#august-21-2026). 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`](/api-reference/introduction#resource-names) arrived on 21 August with the scope names left behind — a read of `/conversations` still needed `read:interviews`. The scopes now match the resources:
```javascript theme={null}
read:conversations // was, and still is, read:interviews
write:conversations
delete:conversations
read:contacts // was, and still is, read:candidates
write:contacts
delete:contacts
```
* **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](/guides/resources/agents#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.
```javascript theme={null}
// Sent:
{ "firstMessage": { "type": "first_message", "text": "Dobrý deň, tu je Jana zo spoločnosti Acme." } }
// Spoken:
// "Dobrý deň, tu je Jana zo spoločnosti Acme. Som virtuálny hlasový asistent."
```
* **A time you name is the time we call**: `scheduleTime` on [`POST /conversations`](/api-reference/conversations/create-conversation), and `scheduledAt` on both call-attempt routes, are now used exactly as sent, including when they fall outside the agent's [calling window](/api-reference/agents/create-agent#retries-and-calling-hours). 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.
```javascript theme={null}
// Agent's window: Mon-Fri 09:00-18:00, contact's own timezone.
// Contact is in Bratislava (UTC+2).
{ "scheduleTime": "2026-08-25T04:00:00Z" } // 06:00 for them, before the window opens
// was: called at 07:00Z (09:00 local), silently moved
// now: called at 04:00Z, as asked
```
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.
```javascript theme={null}
// was: either spelling, depending on which conversation you fetched
{ "language": "slk" } // or "sk"
// now: one spelling, the documented one
{ "language": "sk" }
```
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](/api-reference/agents/create-agent#section-types) 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:
```javascript theme={null}
POST /conversations // was, and still is, POST /interviews
GET /conversations/{id}
GET /contacts // was, and still is, GET /candidates
```
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.
```javascript theme={null}
{
"callConfig": {
"retryPolicy": { "maxAttempts": 3 },
"callWindow": {
"days": ["mon", "tue", "wed", "thu", "fri"],
"start": "09:00",
"end": "18:00",
"timezoneAnchor": { "mode": "contact" }
}
}
}
```
`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`](/api-reference/agents/create-agent) 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.
```javascript theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"contactId": "9f1c7c62-3b58-4a2e-8d31-6b2f0c9d4a11",
"candidateId": "9f1c7c62-3b58-4a2e-8d31-6b2f0c9d4a11",
"agentId": "…"
}
```
* **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](/api-reference/introduction#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](/api-reference/agents/create-agent#retries-and-calling-hours)** documents the fields, both timezone anchors, the multi-zone prefix behaviour, and what a `business_hours` agent means. [Update Agent](/api-reference/agents/update-agent#updating-retries-and-calling-hours) 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.
```javascript theme={null}
POST /agents/composer
{
"type": "PHONE",
"message": "Screen senior backend devs. Ask about Go and Postgres, check notice period and salary expectation, then hand off to a recruiter if they're a strong fit."
}
```
**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.
```javascript theme={null}
{
"type": "human_handoff",
"label": "Sales",
"phoneNumber": "+421900123456",
"message": "I'll put you through to Martin on our sales team now, one moment."
}
```
`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](/api-reference/agents/create-agent#section-types) 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](/guides/resources/agents#custom-agents) 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:
```bash theme={null}
# 1. Reserve a slot — returns documentId, uploadUrl and the contentType to send
POST /agents/{id}/knowledge
{ "fileName": "benefits.pdf", "mimeType": "application/pdf", "sizeBytes": 482133 }
# 2. PUT the raw bytes to uploadUrl, with Content-Type exactly as returned
# Send NO API key on this request — the URL carries its own authorisation
PUT
# 3. Confirm, which verifies the stored file and makes the document usable
POST /agents/{id}/knowledge/{documentId}/complete
```
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](/guides/rate-limiting#endpoints-with-lower-limits).
### Documentation
* **New reference section for the knowledge base**: [Start Knowledge Upload](/api-reference/agents/knowledge/start-upload) walks the three-step flow end to end, with the remaining operations alongside it. The [Agents guide](/guides/resources/agents#knowledge-base) has a short version, [Rate Limiting](/guides/rate-limiting#endpoints-with-lower-limits) documents the tighter per-endpoint limits, and the agents scope table in [Scopes & Permissions](/guides/scopes-and-permissions) now lists these operations.
## 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`:
```javascript theme={null}
POST /agents
{
"name": "Acme Warehouse Screening",
"type": "PHONE",
"focus": "SCREENING",
"language": "EN",
"duration": 15,
"companyPhoneNumberId": "…",
"overrides": {
"companyName": "Acme Manufacturing",
"companyDescription": "Acme makes industrial fasteners and employs 400 people across Slovakia."
}
}
```
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.
```javascript theme={null}
POST /agents
{
"name": "Renewal check-in",
"type": "PHONE",
"language": "EN",
"duration": 10,
"companyPhoneNumberId": "…",
"flow": {
"firstMessage": { "type": "first_message", "text": "Hi {{contact.first_name}}, do you have two minutes?" },
"sections": [{ "type": "sequential", "label": "Intro", "prompt": "Ask how the setup has been working." }],
"lastMessage": { "type": "last_message", "text": "Thanks for your time!" }
}
}
```
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](/guides/resources/agents#custom-agents).
**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.
| Field | What it does |
| - | - |
| `extractionTargets` | Values to pull out of each call. One with an `ideal` also counts toward the match score. |
| `scoringCriteria` | Holistic judgements over the whole call — "handled objections". |
| `outcomes` | Did the call achieve what it was for? Decided met / not met. |
| `outputTags` | Labels the AI may assign. |
| `capture` | Which qualitative artefacts to produce — summary, Q\&A, evaluation, sentiment, recording, transcript. All default to `true`. |
**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 `deliveryId`s**, 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](/guides/webhooks#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`.
```json theme={null}
// before
{ "statusCode": 200, "message": "Success", "data": { "id": "…", "name": "Renewal check-in" }, "traceId": "…" }
// now
{ "id": "…", "name": "Renewal check-in" }
```
**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:
```json theme={null}
// before
{ "statusCode": 200, "message": "Success", "data": { "data": [ … ], "total": 25, "page": 1, "limit": 20, "totalPages": 2 } }
// now
{ "data": [ … ], "total": 25, "page": 1, "limit": 20, "totalPages": 2 }
```
**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](https://api.instaview.sk), **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 `204`s 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](/guides/resources/conversations) 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.
# September 2026
Source: https://docs.instaview.sk/changelog/2026-09
Changelog entries for September 2026
## September 29, 2026
### Behavior Changes
* **A `human_handoff` number has to be a real number, not just the right shape.** Until now a
`phoneNumber` was accepted if it looked like E.164: a `+` and up to 15 digits. A number with
a digit missing, or a national number behind the wrong country code, passed that check, was
saved, and then made every call on the agent fail before anyone was dialled. The number is
now also checked against its country's numbering plan when you
[create](/api-reference/agents/create-agent) or [update](/api-reference/agents/update-agent)
an agent, or send an inline `agent` on
[Create Conversation](/api-reference/conversations/create-conversation):
```json theme={null}
{
"statusCode": 422,
"message": "A human handoff has an invalid phone number: +42190012345. Check the country code and the number of digits.",
"errors": [{ "code": "HUMAN_HANDOFF_INVALID_NUMBER", "message": "...", "blockId": "handoff-1" }]
}
```
**Nothing that dials today stops working**: a number this rejects is one no call on the agent
could have used. **An agent already saved with such a number is not blocked**: requests that
do not send `flow` still go through, and the next request that sends it has to fix the number.
## September 23, 2026
### New Features
* **A conversation now records which agent configuration it ran on.** Editing an agent used
to overwrite its compiled flow in place, so once a question set changed there was no way to
tell what an earlier call had actually said. Every call placed by an agent with a designed
flow is now stamped at dial time with `flowVersion`: a content hash of the compiled
configuration (flow, guardrails and context). It appears on
[`GET /conversations/{id}`](/api-reference/conversations/get-conversation) and the list, on the
conversation and on each entry in `callAttempts`.
```json theme={null}
{
"id": "conversation-uuid",
"flowVersion": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"callAttempts": [
{ "id": "attempt-1", "flowVersion": "4b227777d4dd1fc61c6f884f48641d02b4d121d3fd328cb08b5531fcacdabf8a" },
{ "id": "attempt-2", "flowVersion": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" }
]
}
```
Two calls with the same value heard the same configuration; a retry placed after an edit
carries the new one, as `attempt-2` does above. We keep the configuration behind each value
for as long as a conversation that used it exists, so an audit or a contact exercising their
data rights can be answered from our records.
**It identifies the configuration, it does not reveal it**: the prompt stays on our side.
**Calls placed before today, inbound calls and agents without a designed flow read `null`**:
there is nothing true to backfill them with. The field is not yet part of the webhook
payloads.
## September 14, 2026
### Behavior Changes
* **The agent composer now answers immediately and works in the background.** Designing an
agent is a language-model call that we measured at 15 to 45 seconds, and both composer write
routes used to hold your request open for the whole of it. That is longer than a lot of API
gateways are willing to wait — 30 seconds is a common default — and when one gave up, the
request had already succeeded on our side: the session existed, the agent had been designed,
and the id you needed to reach it went to a connection that was no longer there.
No timeout we control could fix that, because the one that decides the outcome is yours. So
the work moved off the request:
* [`POST /agents/composer`](/api-reference/agents/compose-agent) and
[`POST /agents/composer/{sessionId}/messages`](/api-reference/agents/refine-agent) now
return **202** with `{ "sessionId": "…", "status": "processing" }`.
* [`GET /agents/composer/{sessionId}`](/api-reference/agents/get-composer-session) is the
poll: **202** while a turn is running, **200** when it has finished, with `status` of
`ready` or `failed`. Every five seconds is a sensible interval.
* Or pass `webhookId` when you start a session and be told instead — see below.
**This is a breaking change** to both write routes: they no longer return the agent, and the
`201` is now a `202`. It is the same pattern the
[sourcing endpoints](/api-reference/sourcing/initiate-sourcing) have used since May.
* **A brief the composer cannot build is no longer a `422`.** There is no request left to fail
by the time we know, so the session ends up `failed` and the poll carries the reason, with
the flow validator's errors under `error.details` exactly as the `422` used to carry them.
`COMPOSER_INVALID_RESULT` is worth rephrasing for; any other code is worth retrying
unchanged. The `422` on these routes now means only that a `webhookId` you sent is not
subscribed to the composer events.
* **One turn at a time, refused before you pay for it.** Sending a refinement while another is
still running is a `409` within milliseconds. Previously the second turn ran a full model
generation and was rejected when it tried to save, which cost you a generation to be told no.
* **A failed refinement no longer costs you the agent you had.** The session keeps the preview
its last successful turn produced: you can read it, create from it, or try a different
instruction against it. Only a session whose *first* turn failed has no agent, and posting
that session's id to [`POST /agents`](/api-reference/agents/create-agent) is now a `409`
telling you to poll, rather than a complaint about missing required fields.
### New Features
* **`composer.completed` and `composer.failed` webhooks.** Pass `webhookId` when you start a
session and we will call that webhook for whichever of the two outcomes it subscribes to —
`composer.completed` carrying the same agent the poll would have given you, `composer.failed`
carrying the reason and no agent. Subscribing to one is enough to be accepted, and you then
hear nothing about the other, so subscribe to both if you want every outcome. We check the
subscription when you send the `webhookId` rather than silently delivering nothing later.
Pinned once per session and reused by every refinement of it. Polling always works as well,
and is always
authoritative.
### Fixed
* **A superseded sourcing run no longer reports results that are not its own.** A run that lost
ownership of its session partway through — because a
[`PATCH`](/api-reference/sourcing/patch-sourcing) queued behind it took over, or because an
internal outage lasted long enough for its claim to lapse — used to carry on regardless. It
then marked the session completed and sent `sourcing.completed` with whatever candidates were
on the session at that moment: candidates the *other* run had found, reported before that run
had finished finding them.
The superseded run now stops as soon as it notices, and says nothing. Whoever owns the session
reports its outcome, and a session that ends up with no owner at all is still failed as
`TIMED_OUT` by the usual 30-minute sweep. If you were reconciling sourcing webhooks by
`requestId` and occasionally saw one arrive with candidate counts that did not match what a
later read returned, this was why.
## September 10, 2026
### Fixed
* **An agent no longer says a sentence with a word missing out of the middle of it.** A
`{{contact.*}}` placeholder we hold no value for has always resolved to nothing at all, which is
the right behaviour — the alternative is an agent reading "contact dot organization" out loud —
but nothing told the agent it happens. A first message written as
`"am I through to {{contact.organization}}?"` was spoken, to a contact with no organisation,
as `"am I through to?"`.
The agent is now told what an absent value does and is instructed to reword the line as it says
it, rather than reading the fragment or announcing that something is missing. **Write prompts
that still read correctly without the value anyway** — the repair is a safety net, not a
substitute for a sentence that survives losing a word.
* **A custom agent asked for a human now transfers instead of hanging up.** If your `flow`
contains a `human_handoff` section, the agent used to be carrying two contradictory
instructions: the handoff step told it to transfer, and a standing call-handling rule told
it that a request for a person means saying "a colleague will contact you soon" and ending
the call. The standing rule won, so the one thing a handoff exists for was the one thing
that never happened.
The standing rule is now the transfer-flavoured version for any flow that can transfer: a
request for a person is still honoured immediately, wherever in the call it comes, but it
hands the call to your number rather than ending it. Where a flow reaches several
destinations the agent picks by `label`, which is why a multi-destination flow has always
required one on each.
Nothing changes for an agent with no `human_handoff`. There is nowhere to transfer to, so
it still takes a message and ends the call.
* **`instructions` reaches a custom agent's call.** The field was accepted, stored and
returned in full for an agent with a `flow`, and then never compiled into the prompt. The
effect was worse than an error: an agent given its reference data there, a stock list or a
price card, answered from nothing at all and made the figures up.
It is now compiled as its own reference section, the same way it always has been for a
template agent. **If you have been sending `instructions` alongside a `flow`, the agent
will start using it on its next call.** Worth a read of what you sent, in case it holds
something you did not mean the agent to be able to say out loud.
### Documentation
* **`{{system.today}}`, `{{system.now}}` and `{{system.day_of_week}}` are documented.** They are
not new — every call has substituted them into flow text for as long as `{{contact.*}}` has
worked — but nothing said so, so the only way to find them was to be told. They are filled from
the platform's clock in the contact's own timezone, and they are on
[Create Agent](/api-reference/agents/create-agent#system-variables) with the contact variables.
* **`{{contact.profile}}` is written down, as something not to use in a spoken line.** It is the
rendered contact-context block — a paragraph, not a value — and an agent that meets it in a
first message reads that paragraph out loud.
* **`{{contact.company_name}}` is *your* company, and the docs now say so where you would look.**
It resolves from the company placing the call, so *"I see you work at
`{{contact.company_name}}`"* is fluent, wrong on every call, and rejected by nothing. The
contact's own employer is an ordinary catalogued field.
### Notes
* All of these reach agents you have already created. Nothing to re-save.
## September 9, 2026
### Changed
* **Contact context stopped being a curation chore.** `contextConfig.useContactContext` used to
volunteer a summary built from the fields you had ticked as contact context, and everything
else the contact held was invisible to the agent. It now gives the agent two things: a short
platform-decided set of fields in its **prompt**, and a generated **document it can query
mid-call** holding everything else the contact has a value for. Their documents (a CV, files
you uploaded) are on the same knowledge base as before.
Nothing about the agent switch changed, and no request shape you send changed. What changed is
how much reaches the agent, and it is strictly more.
* **`includeInContactContext` is removed from `/contact-fields`.** It was a per-field allowlist
that shipped empty, so the feature was effectively off for anyone who had not walked the
catalog. Two properties replace it, and neither needs curating:
```javascript theme={null}
GET /contact-fields
{
"key": "amount_due",
"isPii": true,
"allowPiiInContext": false, // yours to set, and only on a PII field
"inEssentialPromptSet": false // ours to decide, read-only
}
```
**Take it out of your payloads.** Sending `includeInContactContext` on a create or update is
now a `400`, as any unknown property is:
```text theme={null}
Validation failed: Property 'includeInContactContext' should not exist
```
Beyond that, if you were setting it on a **non-personal** field you need do nothing: that
field now reaches the agent by default, through the document. If you were setting it on a
field marked `isPii`, that decision was migrated to `allowPiiInContext` and still holds.
"By default" is not "always". A field reaches the agent when it is not `readOnly` and either
is not `isPii` or has been let past that: `company_name`, `company_description`, `agent_name`
and `profile` are `readOnly`, describe the **caller** rather than the person being called,
and are in neither the prompt nor the document whatever you set on them.
* **`isPii` now enforces rather than defaults.** A personal field is excluded from the prompt
AND from the document, which is what keeps `email` and `phone` off every call without anyone
deciding. `allowPiiInContext` is the way past it, per field, and it puts the value in both
halves — a queryable value is one the agent might never think to look up, and for something
like an amount owed that is the whole point. Sending `allowPiiInContext` on a field that is not
`isPii` is a `400` rather than a silent no-op.
### Notes
* A contact's document is generated the first time a context-enabled agent is **scheduled** to
call them, and refreshed before a later call only when a value or the catalog actually changed.
A contact nobody calls costs nothing. An immediate ad-hoc call for a contact with no document
yet runs prompt-only rather than waiting on one, and it is ready for the next call.
* A value written between generation and the dial is not in that call's document. The window is
bounded by the freshness check at dial time.
* The document follows the contact's retention, exactly as their uploaded documents do: it is
deleted, provider copy included, when the contact is deleted or reaches its GDPR expiry.
* It does not appear in `GET /contacts/{id}/documents` and does not count against the five
uploads per contact. It is our projection of data you already hold, not a file you attached.
## September 8, 2026
### Behavior Changes
* **A conditional branch's `condition` is now validated.** It never was. A branch could be stored with a condition the platform could not read — a `type` outside `intent` and `expression`, an `intent` with no `description`, an `expression` with no `field` — and the flow was accepted. Nothing told you, and the agent still took that branch on calls: the compiled instruction for it came out describing nothing, so the route was chosen arbitrarily. That is the failure this closes.
Four new codes join the `errors` array on [Create Agent](/api-reference/agents/create-agent) and [Update Agent](/api-reference/agents/update-agent), each with the `path` of the offending branch:
| Code | Raised when |
| - | - |
| `CONDITIONAL_CONDITION_MISSING` | `condition` is absent, `null`, or not an object |
| `CONDITIONAL_CONDITION_TYPE_UNKNOWN` | `type` is neither `intent` nor `expression` |
| `CONDITIONAL_CONDITION_INVALID` | the type is known but incomplete: an `intent` with a blank `description`, or an `expression` with a blank `field`, an operator outside the documented set, or no `value` for an operator other than `EXISTS` |
| `CONDITIONAL_BRANCH_NOT_AN_OBJECT` | an entry in `branches` is not an object at all |
That last one closes a hole of its own. A `null` in `branches` counted toward the branch total and then every per-branch rule skipped it, so the flow validated clean and the failure surfaced later as a `500` while the prompt was being built. It is a `422` naming the branch now.
```json theme={null}
{
"code": "CONDITIONAL_CONDITION_TYPE_UNKNOWN",
"message": "Unknown branch condition type: variable_exists. Must be one of: intent, expression.",
"path": "sections[0].branches[1].condition"
}
```
**What to check in your own integration.** A payload that sent a condition of your own shape, or left one off, was accepted before and is a `422` now. Everything the API has ever documented for `FlowBranchCondition` still passes unchanged, `EXISTS` still needs no `value`, and `logicalOperator` is still accepted and ignored. The branch you nominate as `defaultBranch` is checked like the rest: its condition is not read while it is the fallthrough, but moving `defaultBranch` later would put it back in charge of a route.
Agents already holding an unreadable condition were repaired rather than left to fail: each one became `{ "type": "intent", "description": "" }`, the label being the only authored statement of what that route means. Their cached prompts were dropped so the next call compiles from the repaired flow. If a repaired branch does not route the way you meant, the label is what to rewrite.
## September 7, 2026
### Added
* **Contact documents**: a contact can now hold documents an agent may look up on a call — a cover letter, application notes, and the contact's own processed CV. Five endpoints under `/contacts/{id}/documents`, mirroring the agent knowledge base exactly: list, start an upload (presigned URL), complete it, rename or deactivate, delete. Same request and response shapes, same file types, same 20 MiB per-file limit, same upload rate limit. Scopes are `read:contacts`, `write:contacts` and `delete:contacts`.
```javascript theme={null}
GET /contacts/{id}/documents
POST /contacts/{id}/documents/uploads // presigned ticket
POST /contacts/{id}/documents/{docId}/complete
PATCH /contacts/{id}/documents/{docId} // title, isActive
DELETE /contacts/{id}/documents/{docId}
```
**A contact's CV shows up on its own.** Once a CV has been processed, the contact lists a document with `source: "CV"`. You did not upload it and cannot delete or rename it here — it is produced from the anonymized text of the CV, which is what the agent can look up. You can deactivate it, and that is how you keep a particular contact's CV out of calls.
**Two limits instead of one.** A contact holds at most **5** uploaded documents (the CV does not count). On top of that, your company's total contact-document storage is capped by plan, and both are **reserved** when the upload is started — pending uploads included — so parallel requests cannot all pass the same check. When the company ceiling is the problem, the `400` says so in numbers:
```text theme={null}
Contact document storage limit reached for this company: 248MB of 250MB in use, and this file needs 3MB more.
```
* **`source` on knowledge documents**: every document — agent or contact — now carries `source`, either `"UPLOAD"` or `"CV"`. On an agent's knowledge base it is always `"UPLOAD"`. Additive; nothing else about the shape changed.
* **Whether documents reach a call is not decided by the API.** One switch decides: the agent must have `contextConfig.useContactContext` enabled. With it on, the contact's active documents are attached to the call as the `candidate` knowledge base and the agent is told they exist and may be looked up, never read aloud. With it off, the call runs exactly as before. Every other knowledge base (`company`, `position`, `reference_documents`) is unaffected.
Contact documents follow the contact's retention: they are deleted — stored files and provider copies included — when the contact reaches its GDPR expiry date.
* **The contact-field catalog now ships thirty-six default fields instead of eleven**: `GET /contact-fields` returns a seeded set covering identity, the contact's employer, orders and invoices, the relationship, appointments and screening, so most of what you would have defined by hand already exists.
Only three of the previous eleven (`order_number`, `is_vip`, `lead_source`) could hold a per-contact value at all; the rest resolve from the contact record, your company or the agent. The new rows are the ones an agent would actually say: `job_title`, `organization`, `department`, `company_size`, `industry`, `website`, `city`, `secondary_phone`, `invoice_number`, `amount_due`, `due_date`, `renewal_date`, `contract_end_date`, `plan_name`, `deal_value`, `lead_status`, `owner_name`, `customer_since`, `last_purchase`, `tags`, `appointment_at`, `years_experience`, `notice_period`, `salary_expectation` and `availability_date`.
Nothing breaks — the response is a list and you iterate it — but two things follow. **If you render every field you get back as a form row, expect thirty-six rows.** And **most** seeded fields are still yours to shadow: define your own field with the same key and it overrides the label and type for your company alone. That is the intended path for `company_size`, `industry` and `lead_status`, which are seeded as `string` because one company's option sets are not another's. The eight reserved keys (`first_name`, `last_name`, `email`, `phone`, `company_name`, `company_description`, `agent_name`, `profile`) refuse even that, because the platform resolves them itself.
Five of them default to `includeInContactContext`, so an agent with `contextConfig.useContactContext` on can refer to them without a token in the flow: `lead_source`, `job_title`, `plan_name`, `owner_name` and `last_purchase`. Everything else is off, because every field that defaults on lengthens every prompt on every call.
Superseded on September 9: `includeInContactContext` is gone and there is no per-field
opt-in any more. See that day's entry.
**`organization` is the contact's employer; `company_name` is yours.** `{{contact.company_name}}` resolves the company **placing** the call and always has — it is labelled "Your company name" for that reason. An author writing *"I see you work at `{{contact.company_name}}`"* got a fluent wrong sentence with no signal, so `organization` now exists for what that author meant.
* **`category` on a contact field**: a new response property on `GET /contact-fields` naming which panel the field belongs to — `identity`, `organisation`, `commercial`, `relationship`, `scheduling`, `recruiting`, `platform` or `custom`.
The list is now ordered by `category` first, then `displayOrder`, then `key`, so iterating the response and breaking on a change of `category` gives you the same panels the dashboard shows. It is set by InstaView and not writable: a field you define is always `custom`, and a field of yours that shadows a seeded one reports the **seeded** field's category, so `job_title` stays under `organisation` for everybody even after you relabel it.
Because `displayOrder` now only separates fields inside one category, each category's numbering restarts at 10 rather than continuing one long sequence. If you were sorting on `displayOrder` alone, sort on `category` first.
* **A `tags` data type**: `dataType: "tags"` stores a `string[]`, and it is the one type that accepts two shapes on the way in — an array, **or** one comma-separated string, so a single CSV cell holding several tags maps onto one field without your side splitting it first.
```javascript theme={null}
PATCH /contacts/{id}
{ "fields": { "tags": ["vip", "renewal"] } }
// Same result
PATCH /contacts/{id}
{ "fields": { "tags": "vip, renewal" } }
```
Entries are trimmed, blanks are dropped (`"vip,, renewal,"` is two tags) and duplicates are removed case-insensitively, keeping the spelling that came first. An empty list is the erase: `[]`, `[""]` and `","` all clear the field, exactly as `null` and `""` do, so you never get a key present with an empty array behind it. A number, a boolean or an object is a `422` rather than a one-element list — a caller sending `42` for a tags field has a mapping bug, and a silent `["42"]` would be spoken on a call.
The seeded `tags` field uses it. `enum` is still the right type when the set is closed and a value outside it should be refused.
* **CSV aliases on every writable seeded field**, in English, Slovak and Czech — `renewal_date` carries `renewal`, `renews` and `dátum obnovenia`; `amount_due` carries `balance`, `outstanding` and `dlžná suma`.
**Nothing consumes them yet.** The current CSV importer maps against a fixed list of built-in contact properties and does not read `csvAliases`, so this changes no import behaviour today — it is catalog metadata, seeded so the catalog is complete before the contact-field import lands. Adding your own with `PATCH /contact-fields/{id}` does not touch InstaView's.
* **`fields` on the contact resource itself**: `POST /contacts` and `PATCH /contacts/{id}` now accept catalogued field values in the body, so creating a contact with its values is one request instead of two.
```javascript theme={null}
POST /contacts
{
"firstName": "Jane", "lastName": "Doe", "email": "jane@example.com",
"fields": { "is_vip": true, "order_number": "SO-40128", "renewal_date": "2026-04-01" }
}
// Merges: a key you omit keeps its value, an explicit null clears one
PATCH /contacts/{id}
{ "fields": { "order_number": "SO-40129", "renewal_date": null } }
```
Before this, the read of a contact's values sat on the contact (`GET /contacts/{id}` returns `fields`) while the write sat somewhere else — and `POST /contacts` dropped any inbound values and always answered `fields: {}`, so the one request where you most want to set them was the one that could not. Field values are contact attributes; the resource for a contact's attributes is the contact.
The shape is the flat one `GET /contacts/{id}` already returns, so read and write agree. **`fields` merges and `metadata` replaces**, in the same request body, on purpose: `metadata` is one opaque document you own, `fields` are individually catalogued values.
**A rejected value takes the whole request with it.** Values are validated against the catalog inside the same transaction as the contact write, so an unknown key or a wrong-typed value answers `422` naming it and nothing is applied — not the contact, not the status, not the other values. A contact that exists without the values you sent is a half-write you cannot detect without re-reading.
* **The `/contacts/{id}/fields` sub-resource is gone.** `GET` returned a strict subset of `GET /contacts/{id}`, and its write did exactly what `PATCH /contacts/{id}` with only `fields` does — same scopes, same guards, same blast radius, since a partial update touches only what you send. One resource, one shape.
`?createMissingFields=true` moved with it and now hangs off both contact writes. Off by default: a key in `fields` that your catalog does not know is a `422` unless you ask for it to be defined, because a typo would otherwise mint a permanent field visible in every agent's variable palette.
### Behavior Changes
* **`metadata` no longer has a reserved key.** `metadata.contactVariables` used to be cut out of what you sent and cut out of what you were shown, because a contact's field values were stored inside that same object. They are stored separately now, so `metadata` is once again exactly the document you put there, `contactVariables` included.
```javascript theme={null}
PATCH /contacts/{id}
{ "metadata": { "contactVariables": { "note": "mine" } } }
// ...comes back verbatim, and reaches no agent
{
"metadata": { "contactVariables": { "note": "mine" } },
"fields": { "order_number": "SO-40128" }
}
```
Nothing about `fields` changes, in either direction. A value still reaches `{{contact.}}` only by being sent as `fields` and passing catalog validation, and replacing `metadata` still cannot disturb the values you have stored — that guarantee is stronger now, not weaker, because the two are no longer the same column. If you were avoiding the key to work around the old reservation, you no longer need to.
***
## September 3, 2026
### Improvements
* **`idealAnswer` is now optional on a flow question**: a `looping` question needs only its `question` text. `POST /agents` and `PATCH /agents/{id}` no longer answer `422 LOOPING_QUESTION_INCOMPLETE` for a question that omits `idealAnswer`, which makes a question set with no right answers, an internal survey or a satisfaction check, expressible for the first time.
```javascript theme={null}
{
"type": "looping",
"questions": [
{ "question": "How has the rollout felt so far?" },
{ "question": "Anything you would change about the process?" }
]
}
```
`idealAnswer` was only ever scoring metadata: it is not spoken, it never reaches the agent's instructions, and scoring has always skipped a question that has none. Requiring it therefore forced callers to invent an ideal answer for a question that has none, and that invented value then decided whether the question looked scoreable.
Nothing changes for a question that sends one, and the scoring rules are unchanged: `importance` or `weight` opts a question into the match score, and a question with no `idealAnswer` cannot be scored at all, so setting `importance` on one has no effect. The same relaxation applies in the visual builder, where the ideal answer field can now be left blank.
## September 2, 2026
### Improvements
* **Owners invited through the API can now set a password**: creating or updating a company with `ownerEmail` still sends the invitation email, and for an address that has no InstaView account yet it now also creates the login identity and sends a separate **set your password** email from Auth0.
Before this, the invitation was the only thing the new owner received. Its link leads to a page that asks them to sign in, and having never signed up anywhere they naturally reached for "Reset password" — which for an address with no account is answered with a success message and no email, deliberately, so that the reset form cannot be used to find out which addresses have accounts. The invitation was valid the whole time and there was no way to tell.
Nothing changes in the request or the response, and nothing changes for an `ownerEmail` that already belongs to an InstaView user: they are assigned as `OWNER` as before, with no extra email. An address that already has a login but no company gets the invitation only, not a password email it did not ask for.
* **The invitation page opens without signing in first**: `/invite/accept` now renders for a visitor with no session, shows which email address the invitation was issued for, and leads with **Create an account and accept**. Signing in is the second option, for people who already have one.
Previously the link redirected straight to the sign-in form, so an invitee with no account saw neither the invitation nor a way to create one. Signing up with the invited address accepts the invitation automatically once the email is verified.
# October 2026
Source: https://docs.instaview.sk/changelog/2026-10
Changelog entries for October 2026
## October 7, 2026
### Behavior Changes
* **Call attempts on online conversations now report `direction: "ONLINE"`.** A browser
session used to be reported as `OUTBOUND`, as if we had dialled the contact. It now reads
`ONLINE` everywhere a call attempt appears: the conversation endpoints and every webhook
payload that carries `callAttempts`. Existing online attempts were updated too, so earlier
conversations read the same way. Phone calls are unchanged.
**If you validate `direction` against a fixed list, add `ONLINE`.** A parser that rejects
unknown values will reject these attempts. See
[Conversations](/guides/resources/conversations).
* **A `human_handoff` block's `message` is now heard on every transfer to its number.** On
phone calls, the transfer used to start with a fixed English "Transferring the call now",
whatever the call's language, and neither the block's `message` nor the default line was
heard. The caller now hears the block's `message`, or InstaView's neutral line in the
call's language if the block has none.
That line is attached to the destination number, so it can also be heard when a caller asks
for a person **before** the flow reaches the step, and is transferred there. Until now that
case always used the neutral line. **Check your `message` texts:** a line that refers to
what happened earlier in the flow ("Your card is blocked now, I'll put you through") would
then be said to someone it is not true for. Move that part into the step before the handoff
and keep `message` about the transfer itself. See
[`human_handoff`](/api-reference/agents/create-agent).
* **Whatever the agent is told to say in the step right before a handoff is now said before
the transfer starts.** The agent used to fold it into the turn that started the transfer,
and on phone calls that turn was never played, so the caller did not hear it. It is now
said in a turn of its own, so the caller hears it and replies before being transferred.
That adds one turn to those handoffs.
## October 5, 2026
### Behavior Changes
* **`analysis.completed` now carries every call attempt's transcript, not only the latest
one's.** The event's `callAttempts` lists every attempt on the conversation, but until now
only the attempt that triggered the event had its `transcript`. On a conversation that
took two calls, the first event carried call 1 with its transcript and the second carried
both calls with call 1's transcript missing. An integration that stores `callAttempts` as
a snapshot of the conversation lost call 1's transcript when the second event arrived.
Each attempt now carries its own `transcript`, the same as on
[`GET /conversations/{id}`](/api-reference/conversations/get-conversation). An attempt
that produced no transcript still omits it. **Payloads get larger** on conversations with
several answered calls, since every transcript is resent with each event.
# Changelog
Source: https://docs.instaview.sk/changelog/introduction
Track all changes, updates, and improvements to the InstaView API
The Changelog provides a chronological record of all notable changes to the InstaView API, including new features, improvements, bug fixes, and deprecations.
## Changelog Format
Each changelog entry is organized by date (year-month) and includes the following categories:
* **Added** - New features, endpoints, or capabilities
* **Changed** - Changes to existing functionality, behavior, or parameters
* **Fixed** - Bug fixes and issue resolutions
* **Deprecated** - Features that are still available but will be removed in a future version
* **Removed** - Features that have been removed from the API
* **Security** - Security improvements and vulnerability fixes
## Recent Updates
View changes from June 2026
View changes from May 2026
View changes from April 2026
View changes from March 2026
## How to Use the Changelog
The Changelog helps you:
* **Stay Updated**: Keep track of new features and improvements
* **Plan Migrations**: Identify deprecated features before they're removed
* **Debug Issues**: Check if a bug you encountered has been fixed
* **Understand Breaking Changes**: Review changes that might affect your integration
## Versioning
The InstaView API uses semantic versioning. Major version changes are indicated in the changelog and may include breaking changes. Minor and patch versions include backward-compatible changes and bug fixes.
## Getting Notifications
To stay informed about API changes:
* Subscribe to our [status page](https://status.instaview.sk) for real-time updates
* Check this Changelog regularly
* Review our [API Reference](/api-reference) for the latest endpoint documentation
**Breaking Changes**: Breaking changes are clearly marked in the changelog. We
recommend reviewing these entries carefully and planning your migration
accordingly.
# API Keys
Source: https://docs.instaview.sk/guides/api-keys
Complete guide to managing InstaView API keys
## Overview
API keys are the primary authentication mechanism for the InstaView API. Each key is cryptographically secure, scoped to a specific company, and has granular permissions through scopes.
## Key Architecture
### Security Features
InstaView API keys are built with enterprise-grade security:
Keyed hashing with a strong server-side secret; plaintext keys are never
stored.
Cryptographically secure random key generation
Complete audit trail for all key lifecycle events
Keys are strictly scoped to prevent cross-company access
### Key Components
Every API key contains:
* **Prefix**: Identifies key type (`sk_` for secret keys)
* **Environment**: `live` for production, `test` for sandbox
* **Identifier**: Unique 32-character string
* **Metadata**: Name, scopes, creation date, etc.
## Creating API Keys
### Via Dashboard
Go to **Settings** → **API Keys** in your dashboard
Click **Create API Key**
```json theme={null}
{
"name": "Production Integration",
"scopes": [
"read:jobs",
"write:jobs",
"read:contacts",
"write:contacts",
"read:conversations",
"write:conversations"
],
"expiresAt": "2025-12-31T23:59:59Z", // Optional
"allowedIPs": [ // Optional
"192.168.1.0/24",
"10.0.0.100"
]
}
```
**Important**: Copy your API key immediately - it will only be shown once!
### Via API (Admin Only)
```javascript theme={null}
// Requires admin JWT authentication
const response = await fetch("https://api.instaview.sk/api-keys", {
method: "POST",
headers: {
Authorization: "Bearer ",
"Content-Type": "application/json",
},
body: JSON.stringify({
companyId: "550e8400-e29b-41d4-a716-446655440000",
name: "Production Integration",
scopes: ["read:jobs", "write:jobs", "read:contacts", "write:contacts"],
}),
});
const data = await response.json();
console.log("API Key:", data.key); // Save this securely!
```
## Scope Configuration
### Choosing the Right Scopes
Apply the principle of least privilege:
```json theme={null}
{
"scopes": [
"read:jobs",
"read:contacts",
"read:conversations",
"read:billing"
]
}
```
Read-only access for dashboards and reporting.
```json theme={null}
{
"scopes": [
"read:jobs",
"read:contacts",
"write:contacts",
"read:conversations"
]
}
```
Contact management with limited conversation access.
```json theme={null}
{
"scopes": [
"read:jobs",
"write:jobs",
"delete:jobs",
"read:contacts",
"write:contacts",
"delete:contacts",
"read:conversations",
"write:conversations",
"delete:conversations",
"read:agents",
"write:agents",
"delete:agents"
]
}
```
Complete access for primary integrations.
```json theme={null}
{
"scopes": [
"read:conversations",
"read:contacts"
]
}
```
Minimal access for processing webhook events.
### Scope Validation
When making API requests, scopes are validated:
```javascript theme={null}
// ✅ Success: Key has write:jobs scope
POST /jobs
Authorization: Bearer sk_live_xxx (scopes: ["write:jobs"])
// ❌ Error: Key lacks write:jobs scope
POST /jobs
Authorization: Bearer sk_live_yyy (scopes: ["read:jobs"])
// Response:
{
"statusCode": 403,
"message": "Insufficient permissions. Required scopes: write:jobs",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
## Key Types
### Regular API Keys
Standard keys associated with a single company:
```javascript theme={null}
// Automatically scoped to the key's company
GET /jobs
Authorization: Bearer sk_live_company_a_key
// Returns only jobs for Company A
```
**Use Cases**:
* Direct integrations
* Internal tools
* Customer-facing applications
### ATS Integration Keys
Special keys for multi-tenant ATS platforms:
```javascript theme={null}
// Must specify companyId for resource access
GET /jobs?companyId=company-b-uuid
Authorization: Bearer sk_live_ats_integration_key
// Can create new companies
POST /companies
Authorization: Bearer sk_live_ats_integration_key
```
**Use Cases**:
* Greenhouse, Lever, or other ATS integrations
* Multi-tenant recruitment platforms
* White-label solutions
ATS keys can only be created by InstaView administrators. Contact
[support](mailto:support@instaview.sk) to request ATS integration access.
## Advanced Configuration
### IP Allowlists
Restrict key usage to specific IP addresses or ranges:
```json theme={null}
{
"name": "Production Integration",
"allowedIPs": [
"192.168.1.100", // Single IP
"10.0.0.0/8", // CIDR notation
"172.16.0.0/12" // Private network range
]
}
```
Requests from non-allowlisted IPs will be rejected:
```json theme={null}
{
"statusCode": 401,
"message": "Invalid API key",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
Every rejected key answers with this same generic `401`, whichever check failed — a wrong
key, a revoked key, an expired key and a blocked IP are deliberately indistinguishable from
outside. Check your key's audit log to see which one it was.
### Expiration Dates
Set automatic key expiration for temporary access:
```javascript theme={null}
{
"name": "Contractor Access",
"expiresAt": "2025-03-31T23:59:59Z",
"scopes": ["read:contacts"]
}
```
**Best Practices**:
* Use expiration for contractor/temporary access
* Set 90-day expiration for high-privilege keys
* Receive email notifications 7 days before expiry
### Metadata and Tags
Add custom metadata for organization:
```json theme={null}
{
"name": "Production Integration",
"metadata": {
"tags": ["production", "primary"],
"environment": "us-east-1",
"owner": "engineering@company.com",
"project": "contact-sync"
}
}
```
## Key Lifecycle Management
### Monitoring Usage
Track key usage in the dashboard:
* **Last Used**: Timestamp of most recent request
* **Request Count**: Total API calls made
* **Error Rate**: Failed requests percentage
* **Top Endpoints**: Most frequently accessed endpoints
### Rotating Keys
Regular rotation improves security:
Generate a replacement key with the same scopes
Deploy the new key to all applications
Verify traffic has moved to the new key
Once traffic has fully migrated, revoke the old key
**Recommended Rotation Schedule**:
* High-privilege keys: Every 90 days
* Read-only keys: Every 180 days
* Compromised keys: Immediately
### Suspending Keys
Temporarily disable a key without deletion:
```javascript theme={null}
// Via dashboard or API
PATCH /api-keys/{keyId}
{
"suspended": true,
"suspensionReason": "Temporary maintenance"
}
```
**Use Cases**:
* Investigating suspicious activity
* Temporary service maintenance
* Debugging integration issues
### Revoking Keys
Permanently revoke a compromised key:
**Revocation is permanent!** Revoked keys cannot be restored. Create a new key
if needed.
```javascript theme={null}
DELETE /api-keys/{keyId}
{
"revocationReason": "Key compromised in security incident"
}
```
All subsequent requests with the revoked key will fail:
```json theme={null}
{
"statusCode": 401,
"message": "Invalid API key",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
## Security Best Practices
❌ **Don't do this:**
```javascript theme={null}
const apiKey = 'sk_live_...'; // Hardcoded!
```
✅ **Do this instead:**
```javascript theme={null}
const apiKey = process.env.INSTAVIEW_API_KEY;
```
* **Production**: `sk_live_xxx` - **Staging**: `sk_test_xxx` -
**Development**: `sk_test_yyy` Never use production keys in non-production
environments.
```javascript theme={null}
// Graceful key rotation
const primaryKey = process.env.INSTAVIEW_PRIMARY_KEY;
const fallbackKey = process.env.INSTAVIEW_FALLBACK_KEY;
async function makeRequest(url, options) {
try {
return await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${primaryKey}`
}
});
} catch (error) {
if (error.status === 401) {
// Primary key failed, try fallback
return await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${fallbackKey}`
}
});
}
throw error;
}
}
```
Set up alerts for: - Unusual request volumes - Requests from unexpected IPs -
High error rates - After-hours usage (if unexpected)
When your application runs from fixed infrastructure:
```json theme={null}
{
"allowedIPs": [
"52.1.2.3", // Production server 1
"52.1.2.4", // Production server 2
"10.0.0.0/16" // VPC network
]
}
```
Note: All API requests must use the `Authorization: Bearer` header with your API key.
## Audit Logs
All API key events are logged for security and compliance:
### Logged Events
* **Key Creation**: Who created it, when, and with what scopes
* **Authentication**: Every successful and failed authentication
* **Scope Changes**: Modifications to key permissions
* **Suspension/Revocation**: Status changes with reasons
* **Configuration Changes**: IP allowlist, expiration date updates
### Accessing Audit Logs
```javascript theme={null}
// Via dashboard: Security → Audit Logs
// Filter by key ID
GET /api-keys/{keyId}/audit-logs
// Filter by event type
GET /api-keys/{keyId}/audit-logs?eventType=authentication_failed
// Date range
GET /api-keys/{keyId}/audit-logs?from=2024-01-01&to=2024-01-31
```
### Example Audit Log Entry
```json theme={null}
{
"id": "log_1234567890",
"eventType": "authentication_success",
"apiKeyId": "key_xxx",
"timestamp": "2024-01-15T10:30:00Z",
"ipAddress": "192.168.1.100",
"userAgent": "MyApp/1.0",
"endpoint": "/jobs",
"metadata": {
"companyId": "550e8400-e29b-41d4-a716-446655440000",
"scopes": ["read:jobs", "write:jobs"]
}
}
```
## Troubleshooting
**Possible Causes**:
* Invalid API key format
* Revoked or expired key
* Suspended key
* Key not properly set in headers
**Solutions**:
* Verify key format: `sk_live_xxx` or `sk_test_xxx`
* Check key status in dashboard
* Ensure header is `Authorization: Bearer sk_xxx`
**Possible Causes**: - Insufficient scopes - IP not in allowlist - Attempting
cross-company access **Solutions**: - Check required scope in error message -
Verify your IP is allowlisted - Confirm you're accessing resources in your
company
**Possible Causes**:
* Exceeded rate limit
* Too many requests in short time
**Solutions**:
* Implement exponential backoff
* Check `Retry-After` header
* Review [rate limiting guide](/guides/rate-limiting)
## Next Steps
Learn about scope-based access control
Understand rate limits and quotas
Handle API errors gracefully
Production-ready integration patterns
# ATS Integration
Source: https://docs.instaview.sk/guides/ats-integration
Build multi-tenant ATS integrations with InstaView
## Overview
ATS (Applicant Tracking System) integration keys allow platforms custom ATS solutions to manage multiple companies through a single API key. This is ideal for multi-tenant applications.
## ATS Key Characteristics
Manage resources across multiple client companies
Create new companies via API
Must specify companyId in requests
Special privileges for platform operations
## Requesting ATS Access
ATS keys can only be created by InstaView administrators.
Email [enterprise@instaview.sk](mailto:enterprise@instaview.sk)
* ATS platform name - Expected number of client companies - Integration use
case - Required scopes
Review terms, security requirements, and SLA
Secure API key with ATS privileges will be issued
## Using ATS Keys
### Creating Companies
```javascript theme={null}
async function createClientCompany(companyData) {
const response = await fetch(
"https://api.instaview.sk/companies",
{
method: "POST",
headers: {
"Authorization": `Bearer ${atsApiKey}`, // ATS key required
"Content-Type": "application/json",
},
body: JSON.stringify({
name: companyData.name,
website: companyData.website,
industry: companyData.industry,
}),
}
);
const data = await response.json();
return data.id; // Save this companyId
}
```
### Accessing Company Resources
All resource requests must include `companyId` query parameter:
```javascript theme={null}
// ❌ Wrong: Missing companyId
GET /jobs
Authorization: Bearer sk_live_ats_key
// ✅ Correct: Includes companyId
GET /jobs?companyId=company-uuid
Authorization: Bearer sk_live_ats_key
```
### Example Integration
```javascript theme={null}
class ATSIntegration {
constructor(atsApiKey) {
this.apiKey = atsApiKey;
this.baseURL = "https://api.instaview.sk/v1";
}
async onboardClient(atsClient) {
// 1. Create company in InstaView
const company = await this.createCompany({
name: atsClient.name,
website: atsClient.website,
});
// 2. Sync jobs
for (const atsJob of atsClient.jobs) {
await this.createJob(company.id, atsJob);
}
// 3. Sync contacts
for (const atsContact of atsClient.contacts) {
await this.createContact(company.id, atsContact);
}
return company.id;
}
// One place for the things every call needs: the key, a deadline, and a check that the
// answer was actually a success before anything reads it as one.
async post(path, body, { companyId } = {}) {
const query = companyId ? `?companyId=${companyId}` : "";
const response = await fetch(`${this.baseURL}${path}${query}`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(
`POST ${path} failed: ${response.status} ${error.message ?? response.statusText}`,
);
}
return await response.json();
}
async createCompany(companyData) {
return await this.post("/companies", companyData);
}
async createJob(companyId, jobData) {
return await this.post("/jobs", jobData, { companyId });
}
async createContact(companyId, contactData) {
return await this.post("/contacts", contactData, { companyId });
}
async syncContacts(companyId, contacts) {
const results = [];
for (const contact of contacts) {
const result = await this.createContact(companyId, contact);
results.push(result);
await this.sleep(200); // Rate limiting
}
return results;
}
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
```
## Data Isolation
Each company's data is strictly isolated:
```javascript theme={null}
// Company A's data
GET /jobs?companyId=company-a-uuid
// Returns: Company A's jobs only
// Company B's data
GET /jobs?companyId=company-b-uuid
// Returns: Company B's jobs only
// ❌ Attempting to access unmanaged company
GET /jobs?companyId=unauthorized-company-uuid
// Returns: 403 Forbidden
```
## Webhook Configuration
**Webhooks are not yet implemented.** The webhook configuration endpoints described below are planned for future implementation. Currently, you need to poll the API to check for updates.
Webhook configuration will be available in a future release. When implemented, you'll be able to set up webhooks per company to receive real-time notifications for events like conversation completion and contact updates.
For now, use polling to check for updates. See the [Webhooks Guide](/guides/webhooks) for current workarounds and best practices.
## Best Practices
```javascript theme={null}
// Map ATS client IDs to InstaView company IDs
const companyMapping = {
'ats-client-123': 'instaview-company-uuid-1',
'ats-client-456': 'instaview-company-uuid-2'
};
function getInstaViewCompanyId(atsClientId) {
return companyMapping[atsClientId];
}
```
Spread requests across companies to avoid rate limits:
```javascript theme={null}
async function syncAllClients(clients) {
for (const client of clients) {
await syncClient(client);
await sleep(1000); // Delay between clients
}
}
```
```javascript theme={null}
async function safeCreateResource(companyId, resourceData) {
try {
return await createResource(companyId, resourceData);
} catch (error) {
if (error.code === 'RESOURCE_ACCESS_DENIED') {
console.error(`No access to company ${companyId}`);
// Remove from managed companies
}
throw error;
}
}
```
## Security Considerations
**ATS keys have elevated privileges**. Implement additional security: - Never
expose ATS keys in client-side code - Use separate keys for production and
testing - Rotate keys every 90 days - Monitor for unusual access patterns -
Implement IP allowlists
## Next Steps
Learn about company management
Set up event notifications
Production integration patterns
Contact enterprise support
# Best Practices
Source: https://docs.instaview.sk/guides/best-practices
Production-ready patterns for InstaView API integration
## Authentication & Security
```javascript theme={null}
// ✅ Good
const apiKey = process.env.INSTAVIEW_API_KEY;
// ❌ Bad
const apiKey = 'sk_live_abc123'; // Hardcoded!
```
* **Production**: `sk_live_xxx`
* **Staging**: `sk_test_xxx`
* **Development**: `sk_test_yyy`
```javascript theme={null}
// ✅ Good: Only what's needed
scopes: ['read:jobs', 'read:contacts']
// ❌ Bad: Unnecessary permissions
scopes: ['read:*', 'write:*', 'delete:*']
```
* Rotate high-privilege keys every 90 days
* Implement graceful key rotation with fallback
* Monitor key usage after rotation
## Error Handling
### Comprehensive Error Handler
```javascript theme={null}
class InstaViewAPI {
async request(method, path, data) {
try {
const response = await fetch(`${this.baseURL}${path}`, {
method,
headers: this.headers,
body: data ? JSON.stringify(data) : null
});
const result = await response.json();
if (!response.ok) {
throw this.createError(result, response.status);
}
return result;
} catch (error) {
if (error.name === 'FetchError') {
// Network error - retry
return this.retryRequest(method, path, data);
}
throw error;
}
}
createError(errorData, statusCode) {
const error = new Error(errorData.message);
error.code = errorData.code;
error.details = errorData.details;
error.statusCode = statusCode;
return error;
}
}
```
## Rate Limiting
### Request Queue
```javascript theme={null}
class RateLimitedQueue {
constructor(requestsPerSecond = 5) {
this.queue = [];
this.processing = false;
this.interval = 1000 / requestsPerSecond;
}
async add(fn) {
return new Promise((resolve, reject) => {
this.queue.push({ fn, resolve, reject });
if (!this.processing) {
this.process();
}
});
}
async process() {
this.processing = true;
while (this.queue.length > 0) {
const { fn, resolve, reject } = this.queue.shift();
try {
const result = await fn();
resolve(result);
} catch (error) {
reject(error);
}
if (this.queue.length > 0) {
await this.sleep(this.interval);
}
}
this.processing = false;
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Usage
const queue = new RateLimitedQueue(5); // 5 requests/second
await queue.add(() => createJob(jobData));
await queue.add(() => createContact(contactData));
```
## Pagination
### Efficient Iteration
```javascript theme={null}
async function* paginateAll(endpoint) {
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await fetch(
`${endpoint}?page=${page}&limit=100`,
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
const result = await response.json();
yield* result.data;
hasMore = result.page < result.totalPages;
page++;
}
}
// Process items as they arrive
for await (const contact of paginateAll('/contacts')) {
await processContact(contact);
}
```
## Data Validation
### Pre-Request Validation
```javascript theme={null}
function validateContactData(data) {
const errors = [];
if (!data.firstName || data.firstName.length < 1) {
errors.push('firstName is required');
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.push('Invalid email format');
}
if (!/^\+\d{10,15}$/.test(data.phoneNumber)) {
errors.push('Phone must be in E.164 format (+1234567890)');
}
if (errors.length > 0) {
throw new ValidationError(errors);
}
}
// Use before API call
try {
validateContactData(contactData);
await createContact(contactData);
} catch (error) {
if (error instanceof ValidationError) {
showUserErrors(error.errors);
}
}
```
## Caching
### Smart Caching Strategy
A TTL decides whether an entry is *served*; on its own it never removes anything. A process
that reads a long tail of distinct contacts and conversations keeps every payload it has ever
fetched, expired or not. Bound the cache as well as time it — the `Map` below is used as an LRU:
a hit re-inserts the key so it moves to the end, and the oldest key is dropped once the cache is
full.
```javascript theme={null}
class CachedAPI {
constructor(apiKey, maxEntries = 1000) {
this.cache = new Map();
this.maxEntries = maxEntries;
this.ttl = {
jobs: 300000, // 5 minutes
contacts: 60000, // 1 minute
conversations: 30000 // 30 seconds
};
}
async get(resource, id) {
const cacheKey = `${resource}:${id}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.ttl[resource]) {
// Re-insert so the key counts as recently used.
this.cache.delete(cacheKey);
this.cache.set(cacheKey, cached);
return cached.data;
}
// Stale: drop it rather than leaving a dead entry to occupy a slot.
this.cache.delete(cacheKey);
const data = await this.fetchFromAPI(resource, id);
this.cache.set(cacheKey, {
data,
timestamp: Date.now()
});
// Map iterates in insertion order, so the first key is the least recently used.
if (this.cache.size > this.maxEntries) {
this.cache.delete(this.cache.keys().next().value);
}
return data;
}
invalidate(resource, id) {
this.cache.delete(`${resource}:${id}`);
}
}
```
## Monitoring & Logging
### Structured Logging
```javascript theme={null}
class APILogger {
static log(level, message, context = {}) {
const log = {
timestamp: new Date().toISOString(),
level,
message,
...context,
service: 'instaview-api'
};
console.log(JSON.stringify(log));
// Send to monitoring service
if (process.env.NODE_ENV === 'production') {
monitoring.track(log);
}
}
static info(message, context) {
this.log('info', message, context);
}
static error(message, error, context) {
this.log('error', message, {
...context,
error: {
message: error.message,
code: error.code,
stack: error.stack
}
});
}
}
// Usage
// Log ids, never the contact's own details. An email address is personal data, and this
// writes to stdout and on to whatever aggregates your production logs — where it is far
// harder to delete on request than it was to write.
APILogger.info('Creating contact', {
jobId: 'job-uuid',
contactId: 'contact-uuid'
});
```
## Testing
### Integration Tests
```javascript theme={null}
describe('InstaView API Integration', () => {
let testJobId;
let testContactId;
beforeAll(async () => {
// Create test job
const job = await createJob({
title: 'Test Job',
status: 'OPEN'
});
testJobId = job.id;
});
afterAll(async () => {
// Cleanup
await deleteJob(testJobId);
});
test('should create and retrieve contact', async () => {
// Create contact
const contact = await createContact({
jobId: testJobId,
firstName: 'Test',
lastName: 'User',
email: 'test@example.com',
phoneNumber: '+1234567890'
});
testContactId = contact.id;
expect(contact.firstName).toBe('Test');
// Retrieve contact
const retrieved = await getContact(testContactId);
expect(retrieved.id).toBe(testContactId);
expect(retrieved.email).toBe('test@example.com');
});
});
```
## Performance
Group related API calls together
Use Promise.all() for independent calls
Reuse HTTP connections
Enable gzip compression
## Deployment Checklist
✓ API keys in environment variables\
✓ Different keys for each environment\
✓ Secrets not in version control
✓ Comprehensive error handling\
✓ Retry logic with exponential backoff\
✓ Circuit breakers for failures
✓ Logging for all API calls\
✓ Error tracking (Sentry, etc.)\
✓ Performance monitoring\
✓ Alert on rate limit hits
✓ API key rotation schedule\
✓ IP allowlists configured\
✓ HTTPS only\
✓ Webhook signature verification
## Next Steps
Handle errors gracefully
Optimize API usage
Real-time event notifications
Complete API documentation
# Error Handling
Source: https://docs.instaview.sk/guides/error-handling
Learn how to handle errors and exceptions in the InstaView API
## Overview
The InstaView API uses standard HTTP status codes and returns structured error responses to help you diagnose and handle issues in your integration.
## Response Structure
Whether a call succeeded is carried by the HTTP status code, not by a field in the body.
Read the status first, then the body.
### Success Response
The body **is** the resource. There is no wrapper around it:
```json theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Senior Engineer"
}
```
List endpoints return a page object instead — see [Pagination](/guides/pagination).
### Error Response
A 4xx or 5xx answers with a flat error object:
```json theme={null}
{
"statusCode": 404,
"message": "Job not found",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"correlationId": "req_01HQ8XZ",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
Some 4xx responses add an `errors` array holding the individual problems — a failed
validation lists one entry per rejected field, and an invalid conversation flow lists one
entry per bad block, each with the path of the block it is on:
```json theme={null}
{
"statusCode": 422,
"message": "Flow validation failed",
"errors": [
{ "path": "sections[0].blocks[2]", "message": "Question block has no text" }
],
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
Every response also carries an `x-trace-id` header holding the same trace id as the body's
`traceId`. Quote it when you write to support — it is the fastest way for us to find the
exact request. It is present on success responses too, which have no body field for it.
## HTTP Status Codes
| Code | Meaning | Usage |
| - | - | - |
| 200 | OK | Successful GET, PATCH, DELETE requests |
| 201 | Created | Successful POST request creating a resource |
| Code | Meaning | Common Causes |
| - | - | - |
| 400 | Bad Request | Invalid request body, validation errors |
| 401 | Unauthorized | Missing or invalid API key |
| 403 | Forbidden | Insufficient permissions, wrong company |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Duplicate resource, state conflict |
| 422 | Unprocessable | Valid JSON but semantic errors |
| 429 | Too Many Requests | Rate limit exceeded |
| Code | Meaning | Action |
| - | - | - |
| 500 | Internal Server Error | Retry with exponential backoff |
| 502 | Bad Gateway | Temporary issue, retry |
| 503 | Service Unavailable | Maintenance, retry later |
| 504 | Gateway Timeout | Request timeout, retry |
## Common Errors
There is no separate machine-readable error code, and no nested `error` object: the body is the
flat one shown above. Branch on `statusCode`, and use `message` for the detail — the sections
below give the real wording for the cases you are most likely to hit.
### Authentication Errors
No `Authorization` header, or one that is not a bearer token.
```json theme={null}
{
"statusCode": 401,
"message": "API key required. Provide Authorization: Bearer header.",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
The key does not exist, has been revoked, has expired, has been suspended, or the request came
from an address outside the key's IP allowlist.
```json theme={null}
{
"statusCode": 401,
"message": "Invalid API key",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
All of those answer with the same sentence on purpose. A message that distinguished
"revoked" from "never existed" would confirm which keys are real to whoever is guessing, so
the reason is written to your audit log rather than to the response. Check the key in the
dashboard, or quote the `traceId` to support.
### Permission Errors
The key authenticated, but does not carry the scope the route requires. The message names the
scopes that were needed.
```json theme={null}
{
"statusCode": 403,
"message": "Insufficient permissions. Required scopes: write:jobs",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
**Solution**: add the scope to the key, or use a key that already has it. See
[Scopes and Permissions](/guides/scopes-and-permissions).
The resource exists but belongs to a different company.
```json theme={null}
{
"statusCode": 403,
"message": "Access denied to this company",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
Several routes answer `404` rather than `403` in this situation — `GET /jobs/{id}` and
`DELETE /jobs/{id}` among them — so that an id belonging to another company is
indistinguishable from one that never existed. Do not branch on the status to tell a
permission problem from a missing resource; the reference page for each endpoint says which
it returns.
### Validation Errors
One or more fields failed validation. `message` is always the constant `"Validation failed"`;
the detail is in `errors`, one entry per rejected constraint.
```json theme={null}
{
"statusCode": 400,
"message": "Validation failed",
"errors": [
"Email must be a valid email address",
"Job ID must be a valid UUID",
"Property 'nickname' should not exist"
],
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4"
}
```
**Solution**: show `errors` to whoever made the request — the entries are already written for
a human. Note that an unknown property is a rejection, not something we ignore.
A custom agent's conversation flow is structurally valid JSON but not a usable flow. Each
entry carries the path of the block it is on.
```json theme={null}
{
"statusCode": 422,
"message": "Flow validation failed",
"errors": [
{ "path": "sections[0].blocks[2]", "message": "Question block has no text" }
],
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
### Resource Errors
The id names nothing your key can reach: it does not exist, it has been deleted, or — on the
routes noted above — it belongs to another company.
```json theme={null}
{
"statusCode": 404,
"message": "Job not found",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
The write cannot be applied to the resource in its current state.
```json theme={null}
{
"statusCode": 409,
"message": "Candidate with this phone number already exists for this company.",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
### Rate Limiting
This response carries the limit that was hit alongside the usual fields, and a `Retry-After`
header with the same number of seconds as `retryAfter`.
```json theme={null}
{
"statusCode": 429,
"message": "Rate limit exceeded",
"error": "Too Many Requests",
"retryAfter": 45,
"limit": 300,
"remaining": 0,
"reset": 1705320120
}
```
**Solution**: wait `retryAfter` seconds, then retry with exponential backoff. See
[Rate Limiting](/guides/rate-limiting).
### Billing Errors
**`402` is the billing code and `403` is the permissions one.** Anything you cannot do because of
money answers `402`; anything you cannot do because of who you are answers `403`. You can switch on
the status alone, without reading the message.
The company has no active or trialing subscription, so nothing can be scheduled.
```json theme={null}
{
"statusCode": 402,
"message": "No active subscription found. Please subscribe to schedule interviews.",
"error": "PAYMENT_REQUIRED",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4"
}
```
**Solution**: subscribe. See [Billing](/guides/resources/billing).
There is a subscription, but the request costs more than the company has left — or it would
cross the monthly spending cap. A `billing` object carries the numbers so you do not have to
parse them out of the message.
```json theme={null}
{
"statusCode": 402,
"message": "Insufficient minutes available to schedule 10 interviews (50 minutes total). Please purchase additional minutes.",
"error": "PAYMENT_REQUIRED",
"billing": {
"billingSystem": "minutes",
"requiredMinutes": 50,
"availableMinutes": 12,
"suggestedAction": "purchase_minutes"
},
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4"
}
```
A credits-based company gets the same object in its own units — `"billingSystem": "credits"`
with `requiredCredits` and `availableCredits` — matching the split
[`GET /billing/usage`](/api-reference/billing/get-usage) already returns. Read `billingSystem`
first and then the matching pair; do not assume minutes.
`suggestedAction` is one of `purchase_minutes`, `upgrade_plan`, `increase_spending_cap` or
`contact_support`. `billing` is **absent** when there are no figures to report, so treat a
missing object as "unknown", never as zero.
**Solution**: top up, raise the cap, or split the request into batches you can afford.
The two numbers answer two different questions. `requiredMinutes - availableMinutes` is how
much to **buy** to run the request unchanged. To **split** it instead, size the next batch
against `availableMinutes` alone: with 12 minutes left and a 5-minute agent, two conversations
fit — the 38-minute difference is not a batch of anything.
## Error Handling Patterns
### Basic Error Handling
Two things a bare request does not do for you: bound how long it can take, and treat a non-2xx
answer as a failure. Without the first, a stalled service leaves the call pending forever;
without the second, the error body parses just as happily as a job would and the code carries
on as though it had one.
```javascript Node.js theme={null}
class InstaViewError extends Error {
constructor(body, status) {
super(body.message ?? `Request failed with status ${status}`);
this.name = 'InstaViewError';
this.statusCode = body.statusCode ?? status;
// Present on a rejected body; one entry per rejected constraint.
this.errors = body.errors ?? [];
// Quote this to support — it identifies the exact request.
this.traceId = body.traceId;
}
}
async function createJob(jobData) {
const response = await fetch('https://api.instaview.sk/jobs', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.INSTAVIEW_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(jobData),
signal: AbortSignal.timeout(10_000)
});
if (!response.ok) {
// A 502 from a proxy may not be JSON at all.
const body = await response.json().catch(() => ({}));
throw new InstaViewError(body, response.status);
}
return await response.json();
}
```
```python Python theme={null}
import os
import requests
class InstaViewError(Exception):
def __init__(self, body, status):
self.status_code = body.get('statusCode', status)
self.message = body.get('message', f'Request failed with status {status}')
# Present on a rejected body; one entry per rejected constraint.
self.errors = body.get('errors', [])
# Quote this to support - it identifies the exact request.
self.trace_id = body.get('traceId')
super().__init__(self.message)
def create_job(job_data):
response = requests.post(
'https://api.instaview.sk/jobs',
headers={
'Authorization': f'Bearer {os.environ["INSTAVIEW_API_KEY"]}',
'Content-Type': 'application/json'
},
json=job_data,
timeout=10
)
if not response.ok:
try:
body = response.json()
except ValueError:
body = {}
raise InstaViewError(body, response.status_code)
return response.json()
```
```php PHP theme={null}
statusCode = $body['statusCode'] ?? $status;
$this->errors = $body['errors'] ?? [];
$this->traceId = $body['traceId'] ?? null;
parent::__construct($body['message'] ?? "Request failed with status {$status}");
}
}
function createJob($jobData) {
$apiKey = getenv('INSTAVIEW_API_KEY');
$ch = curl_init('https://api.instaview.sk/jobs');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($jobData));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json'
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$result = json_decode($response, true) ?? [];
if ($httpCode >= 400) {
throw new InstaViewError($result, $httpCode);
}
return $result;
}
try {
$job = createJob($jobData);
} catch (InstaViewError $e) {
echo "Failed: {$e->statusCode} - {$e->getMessage()} (trace {$e->traceId})\n";
}
?>
```
### Comprehensive Error Handler
Branch on `statusCode`. There is no `code` field to switch on, and the cases that share a
status are separated by `message` rather than by a machine-readable discriminator — so treat
the message as text for a human, and let the status drive the control flow.
```javascript theme={null}
class InstaViewClient {
constructor(apiKey) {
this.apiKey = apiKey;
this.baseURL = 'https://api.instaview.sk/v1';
}
async request(method, path, data = null) {
const response = await fetch(`${this.baseURL}${path}`, {
method,
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
body: data ? JSON.stringify(data) : null,
signal: AbortSignal.timeout(10_000)
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
return this.handleError(body, response.status, response.headers);
}
return await response.json();
}
handleError(body, statusCode, headers) {
const error = new InstaViewError(body, statusCode);
switch (statusCode) {
case 401:
// The key is not usable: revoked, expired, suspended, or outside its IP allowlist.
// Retrying will not help — stop and raise it with whoever owns the key.
console.error('Authentication failed:', error.message);
throw error;
case 403:
// Usually a missing scope; the message names the scopes the route needed.
console.error('Permission denied:', error.message);
throw error;
case 400:
case 422:
// `errors` holds one entry per rejected constraint, already written for a human.
console.error('Rejected:', error.errors);
throw error;
case 429:
console.log(`Rate limited. Retry after ${headers.get('Retry-After')}s`);
throw error;
case 404:
console.warn(`${path} not found`);
return null; // Handle gracefully
case 402:
// No active subscription, or no balance left.
console.error('Billing:', error.message);
throw error;
default:
console.error(`Unexpected ${statusCode}:`, error.message, error.traceId);
throw error;
}
}
}
```
### Retry with Exponential Backoff
```javascript theme={null}
async function retryWithBackoff(fn, maxRetries = 3, baseDelay = 1000) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
// A rate limit is the one 4xx worth retrying, and it tells you how long to wait.
if (error.statusCode === 429) {
await sleep(error.retryAfter ? error.retryAfter * 1000 : baseDelay);
continue;
}
// Every other 4xx is a problem with the request itself. Sending it again
// unchanged produces the same answer.
if (error.statusCode >= 400 && error.statusCode < 500) {
throw error;
}
// Server errors and timeouts: back off and try again.
const delay = baseDelay * Math.pow(2, attempt);
console.log(`Attempt ${attempt + 1} failed. Retrying in ${delay}ms...`);
await sleep(delay);
}
}
throw lastError;
}
// Usage
const job = await retryWithBackoff(async () => {
return await client.createJob(jobData);
});
```
Retrying a **write** is not free of consequences — nothing deduplicates it. See
[Retrying a Write Safely](#retrying-a-write-safely) below.
## Validation Error Handling
A rejected request body answers `400` with `message: "Validation failed"` and an `errors` array
holding one string per rejected constraint. The strings are written for a human and are not
keyed by field, so match them to your form fields yourself if you need to place them:
```javascript theme={null}
// The messages are prose, so match on the field rather than parsing them.
const FIELDS = ['email', 'phoneNumber', 'jobId', 'firstName', 'lastName'];
const FIELD_OF = (message) =>
FIELDS.find((field) => message.toLowerCase().includes(field.toLowerCase()));
async function createContactWithValidation(contactData) {
try {
return await client.createContact(contactData);
} catch (error) {
if (error.statusCode === 400 && error.errors.length > 0) {
for (const message of error.errors) {
showFieldError(FIELD_OF(message), message);
}
return { success: false, errors: error.errors };
}
throw error;
}
}
function showFieldError(field, message) {
// Display error next to form field
const target = document.getElementById(`${field}-error`);
if (target) {
target.textContent = message;
}
}
```
## Retrying a Write Safely
**There is no `Idempotency-Key` header.** No endpoint reads one, and sending it has no
effect — the request is processed as if it were absent. This page previously showed a worked
`Idempotency-Key` example; it never did anything, and it is gone.
Retries are **not** deduplicated for you. What that means depends on the method:
| Method | Retry twice and you get |
| - | - |
| `GET` | The same answer. Safe to retry freely. |
| `PATCH` | The same end state — you are setting fields to given values. Safe. |
| `DELETE` | The delete, then a `404`. Treat the `404` as success on a retry. |
| `POST` | **Two resources.** Two contacts, or two conversations — and a second conversation means a second call to the same person. |
So the only case that needs care is `POST`, and the risk that matters is a duplicate
conversation: a timeout you retry can place two calls.
### Do this instead
* **Never retry a `POST` on a timeout without checking.** A gateway timeout does not mean the
write did not happen. Read before you retry: a `GET /contacts?search=…` or a
`GET /conversations?contactId=…` tells you whether the first attempt landed.
* **Keep your own key.** Put your own identifier in `metadata` on the way in
(`metadata.externalId`), and search for it before retrying. It is the cheapest way to make
your side of the exchange idempotent, and it survives your process restarting.
* **Only retry the retryable.** `5xx` and `429` are worth another attempt with backoff. A `4xx`
other than `429` will fail again identically; retrying it just delays the error.
```javascript theme={null}
// Both calls go through a helper that bounds the request and refuses to treat an error body
// as a result — a lookup that silently "found nothing" because it read a 403 would send the
// second call this function exists to prevent.
async function apiJson(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${apiKey}`,
...options.headers,
},
signal: options.signal ?? AbortSignal.timeout(10_000),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new InstaViewError(body, response.status);
}
return await response.json();
}
// Create a conversation at most once, keyed on something of your own.
async function createConversationOnce(externalId, body) {
const existing = await apiJson(
`https://api.instaview.sk/conversations?contactId=${body.contactId}`,
);
const already = existing.data.find((c) => c.metadata?.externalId === externalId);
if (already) return already;
return await apiJson("https://api.instaview.sk/conversations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...body, metadata: { ...body.metadata, externalId } }),
});
}
```
## Logging and Monitoring
Log errors for debugging and monitoring:
```javascript theme={null}
class ErrorLogger {
static log(error, context = {}) {
const errorLog = {
timestamp: new Date().toISOString(),
statusCode: error.statusCode,
message: error.message,
errors: error.errors,
// The id we need to find your request in our logs.
traceId: error.traceId,
context: context,
stack: error.stack
};
// Send to logging service
console.error(JSON.stringify(errorLog));
// Send to error tracking (Sentry, etc.)
if (process.env.NODE_ENV === 'production') {
Sentry.captureException(error, {
extra: errorLog
});
}
}
}
// Usage
try {
await createJob(jobData);
} catch (error) {
ErrorLogger.log(error, {
operation: 'createJob',
jobData: jobData,
userId: currentUser.id
});
throw error;
}
```
## Best Practices
```javascript theme={null}
const response = await fetch(url, {
// Without a deadline a stalled service leaves this pending forever.
signal: AbortSignal.timeout(10_000)
});
if (!response.ok) {
// The body carries statusCode, message and traceId — and parses just as
// happily as a success would, which is why the check has to come first.
const body = await response.json().catch(() => ({}));
throw new InstaViewError(body, response.status);
}
return await response.json();
```
```javascript theme={null}
class DetailedError extends Error {
constructor(error, context) {
super(error.message);
this.statusCode = error.statusCode;
this.errors = error.errors;
this.traceId = error.traceId;
this.context = context; // Add operation context
}
}
// Ids, not the contact's own details: an email address is personal data, and an
// error log is one of the hardest places to delete it from later.
throw new DetailedError(apiError, {
operation: 'createContact',
contactId: contact.id,
jobId: data.jobId
});
```
```javascript theme={null}
// ❌ Don't expose the key, or the contact's personal data
console.error('Failed:', error, apiKey, data.email);
// ✅ Log safely
console.error('Failed:', error.statusCode, error.message, error.traceId);
```
```javascript theme={null}
class CircuitBreaker {
constructor(threshold = 5, timeout = 60000) {
this.failureCount = 0;
this.threshold = threshold;
this.timeout = timeout;
this.state = 'CLOSED';
this.nextAttempt = Date.now();
}
async execute(fn) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) {
throw new Error('Circuit breaker is OPEN');
}
this.state = 'HALF_OPEN';
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
onSuccess() {
this.failureCount = 0;
this.state = 'CLOSED';
}
onFailure() {
this.failureCount++;
if (this.failureCount >= this.threshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.timeout;
}
}
}
```
## Next Steps
Handle rate limits properly
Production-ready patterns
Fix authentication errors
See all error codes per endpoint
# Pagination
Source: https://docs.instaview.sk/guides/pagination
Learn how to efficiently paginate through large result sets
## Overview
List endpoints in the InstaView API support pagination to handle large datasets efficiently. Understanding pagination helps you retrieve data optimally without hitting rate limits or performance issues.
## Pagination Parameters
All list endpoints accept these query parameters:
Page number to retrieve (1-indexed)
Number of items per page (max: 100)
### Example Request
```bash theme={null}
GET /contacts?page=2&limit=50
```
## Response Format
Paginated responses include metadata about the result set:
```json theme={null}
{
"data": [
{ "id": "contact-1", "firstName": "John", "lastName": "Doe" },
{ "id": "contact-2", "firstName": "Jane", "lastName": "Smith" }
],
"total": 250,
"page": 2,
"limit": 50,
"totalPages": 5
}
```
The page is the response body itself — there is no wrapper around it. `data` holds this
page's items and the counts sit beside it. To find out whether another page exists, compare
`page` against `totalPages`.
### Pagination Metadata
This page's items
Total number of items across all pages
Current page number
Items per page
Total number of pages
## Pagination Strategies
### Sequential Pagination
Iterate through pages sequentially:
```javascript Node.js theme={null}
async function getAllContacts() {
const allContacts = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await fetch(
`https://api.instaview.sk/contacts?page=${page}&limit=100`,
{
headers: { 'Authorization': `Bearer ${apiKey}` }
}
);
const result = await response.json();
allContacts.push(...result.data);
hasMore = result.page < result.totalPages;
page++;
}
return allContacts;
}
```
```python Python theme={null}
def get_all_contacts():
all_contacts = []
page = 1
has_more = True
while has_more:
response = requests.get(
f'https://api.instaview.sk/contacts',
params={'page': page, 'limit': 100},
headers={'Authorization': f'Bearer {api_key}'}
)
result = response.json()
all_contacts.extend(result['data'])
has_more = result['page'] < result['totalPages']
page += 1
return all_contacts
```
```php PHP theme={null}
$page, 'limit' => 100]);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $apiKey
]);
$response = curl_exec($ch);
$result = json_decode($response, true);
curl_close($ch);
$allContacts = array_merge(
$allContacts,
$result['data']
);
$hasMore = $result['page'] < $result['totalPages'];
$page++;
}
return $allContacts;
}
?>
```
### Rate-Limited Pagination
Add delays between requests to avoid rate limits:
```javascript theme={null}
async function paginateWithRateLimit(endpoint, delayMs = 200) {
const results = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await fetch(
`${endpoint}?page=${page}&limit=100`,
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
const result = await response.json();
results.push(...result.data);
hasMore = result.page < result.totalPages;
page++;
// Wait between requests
if (hasMore) {
await sleep(delayMs);
}
}
return results;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Usage
const contacts = await paginateWithRateLimit(
'https://api.instaview.sk/contacts',
250 // 250ms delay
);
```
### Parallel Pagination
Fetch multiple pages concurrently (if you know the total):
```javascript theme={null}
async function paginateParallel(endpoint, limit = 100) {
// First, get total count
const firstPage = await fetch(
`${endpoint}?page=1&limit=${limit}`,
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
const result = await firstPage.json();
const totalPages = result.totalPages;
// Fetch remaining pages in parallel (with concurrency limit)
const pagePromises = [];
const concurrency = 5; // Max 5 concurrent requests
for (let page = 1; page <= totalPages; page += concurrency) {
const batch = [];
for (let i = 0; i < concurrency && (page + i) <= totalPages; i++) {
batch.push(
fetch(`${endpoint}?page=${page + i}&limit=${limit}`, {
headers: { 'Authorization': `Bearer ${apiKey}` }
})
);
}
const responses = await Promise.all(batch);
const data = await Promise.all(responses.map(r => r.json()));
pagePromises.push(...data);
}
// Flatten results
return pagePromises.flatMap(r => r.data);
}
```
### Generator-Based Pagination
Use generators for memory-efficient iteration:
```javascript theme={null}
async function* paginateContacts(limit = 100) {
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await fetch(
`https://api.instaview.sk/contacts?page=${page}&limit=${limit}`,
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
const result = await response.json();
yield* result.data; // Yield items one by one
hasMore = result.page < result.totalPages;
page++;
}
}
// Usage: Process items one at a time
for await (const contact of paginateContacts()) {
await processContact(contact);
// Memory-efficient: only one page in memory at a time
}
```
## Filtering with Pagination
Combine filters with pagination:
```javascript theme={null}
async function getContactsForJob(jobId, limit = 50) {
const contacts = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const response = await fetch(
`https://api.instaview.sk/contacts?` +
`jobId=${jobId}&page=${page}&limit=${limit}`,
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
const result = await response.json();
contacts.push(...result.data);
hasMore = result.page < result.totalPages;
page++;
}
return contacts;
}
```
### Complex Filtering
```javascript theme={null}
// Multiple filters with pagination
async function searchContacts(filters) {
const params = new URLSearchParams({
...filters,
page: 1,
limit: 100
});
const response = await fetch(
`https://api.instaview.sk/contacts?${params}`,
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
return await response.json();
}
// Usage
const results = await searchContacts({
jobId: 'job-uuid',
status: 'IN_PROCESS',
search: 'engineer'
});
```
## Optimizing Page Size
Choose appropriate page sizes:
**Use When**:
* Displaying in UI with client-side pagination
* Network is slow or unreliable
* Processing items individually
**Pros**:
* Faster initial response
* Less memory usage
* Better for mobile clients
**Cons**:
* More API calls required
* Higher overhead for large datasets
**Use When**:
* Batch processing operations
* Syncing with external systems
* General-purpose pagination
**Pros**:
* Good balance of speed and efficiency
* Reasonable memory usage
* Fewer API calls
**Cons**:
* May be slow for poor connections
**Use When**:
* Bulk export operations
* One-time data migrations
* Analytics processing
**Pros**:
* Minimal API calls
* Fastest total time for large datasets
**Cons**:
* Higher memory usage
* Longer individual response times
* May hit rate limits faster
### Dynamic Page Sizing
Adjust page size based on response time:
```javascript theme={null}
class AdaptivePaginator {
constructor(initialLimit = 100) {
this.limit = initialLimit;
this.targetResponseTime = 2000; // 2 seconds
}
async fetchPage(endpoint, page) {
const startTime = Date.now();
const response = await fetch(
`${endpoint}?page=${page}&limit=${this.limit}`,
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
const responseTime = Date.now() - startTime;
this.adjustLimit(responseTime);
return await response.json();
}
adjustLimit(responseTime) {
if (responseTime > this.targetResponseTime && this.limit > 20) {
this.limit = Math.max(20, Math.floor(this.limit * 0.8));
console.log(`Reduced page size to ${this.limit}`);
} else if (responseTime < this.targetResponseTime / 2 && this.limit < 100) {
this.limit = Math.min(100, Math.floor(this.limit * 1.2));
console.log(`Increased page size to ${this.limit}`);
}
}
}
// Usage
const paginator = new AdaptivePaginator();
let page = 1;
let hasMore = true;
while (hasMore) {
const result = await paginator.fetchPage(endpoint, page);
processData(result.data);
hasMore = result.page < result.totalPages;
page++;
}
```
## Caching Paginated Results
Cache pages to reduce API calls:
```javascript theme={null}
class PaginationCache {
constructor(ttl = 300000) { // 5 minutes
this.cache = new Map();
this.ttl = ttl;
}
getKey(endpoint, page, limit) {
return `${endpoint}:${page}:${limit}`;
}
get(endpoint, page, limit) {
const key = this.getKey(endpoint, page, limit);
const cached = this.cache.get(key);
if (cached && Date.now() - cached.timestamp < this.ttl) {
console.log(`Cache hit: page ${page}`);
return cached.data;
}
return null;
}
set(endpoint, page, limit, data) {
const key = this.getKey(endpoint, page, limit);
this.cache.set(key, {
data,
timestamp: Date.now()
});
}
}
const cache = new PaginationCache();
async function fetchPageWithCache(endpoint, page, limit) {
// Check cache first
const cached = cache.get(endpoint, page, limit);
if (cached) return cached;
// Fetch from API
const response = await fetch(
`${endpoint}?page=${page}&limit=${limit}`,
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
const result = await response.json();
// Cache the result
cache.set(endpoint, page, limit, result);
return result;
}
```
## Progress Tracking
Show progress for long-running pagination:
```javascript theme={null}
async function paginateWithProgress(endpoint, onProgress) {
// Get total count
const firstPage = await fetch(
`${endpoint}?page=1&limit=100`,
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
const firstResult = await firstPage.json();
const total = firstResult.total;
const totalPages = firstResult.totalPages;
const allItems = [...firstResult.data];
onProgress(allItems.length, total, 1, totalPages);
// Fetch remaining pages
for (let page = 2; page <= totalPages; page++) {
const response = await fetch(
`${endpoint}?page=${page}&limit=100`,
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
const result = await response.json();
allItems.push(...result.data);
onProgress(allItems.length, total, page, totalPages);
}
return allItems;
}
// Usage
const contacts = await paginateWithProgress(
'https://api.instaview.sk/contacts',
(current, total, page, totalPages) => {
const percent = Math.round((current / total) * 100);
console.log(`Progress: ${percent}% (Page ${page}/${totalPages})`);
}
);
```
## Best Practices
```javascript theme={null}
// ✅ Efficient: Fewer API calls
?page=1&limit=100
// ❌ Inefficient: Too many calls for 1000 items
?page=1&limit=10 // 100 API calls needed
```
```javascript theme={null}
const result = await fetch(endpoint);
const data = await result.json();
if (!data.data || data.data.length === 0) {
console.log('No results found');
return [];
}
return data.data;
```
```javascript theme={null}
// Validate before making request
function validatePagination(page, limit) {
if (page < 1) {
throw new Error('Page must be >= 1');
}
if (limit < 1 || limit > 100) {
throw new Error('Limit must be between 1 and 100');
}
}
```
```javascript theme={null}
async function fetchPageWithRetry(endpoint, page, limit, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await fetch(
`${endpoint}?page=${page}&limit=${limit}`,
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
} catch (error) {
if (i === retries - 1) throw error;
await sleep(1000 * Math.pow(2, i)); // Exponential backoff
}
}
}
```
```javascript theme={null}
// ❌ Bad: Load all 10,000 items into memory
const allContacts = await getAllContacts();
// ✅ Good: Process page by page
for await (const contact of paginateContacts()) {
await processContact(contact);
// Only one page in memory at a time
}
```
## Troubleshooting
```json theme={null}
// Request: ?page=100 (but only 5 pages exist)
{
"data": [],
"total": 250,
"page": 100,
"limit": 50,
"totalPages": 5
}
```
Returns an empty `data` array, not an error.
```json theme={null}
// Request: ?limit=500 (exceeds maximum)
{
"statusCode": 400,
"message": "Validation failed",
"errors": ["limit must not be greater than 100"],
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
## Next Steps
Avoid rate limits during pagination
Production-ready patterns
Handle pagination errors
See all paginated endpoints
# Rate Limiting
Source: https://docs.instaview.sk/guides/rate-limiting
Rate limiting for the InstaView API
## Overview
The InstaView API implements production-ready rate limiting to ensure fair usage and system stability. Rate limits are enforced per API key with two-tier protection:
* **Per-minute limits**: Burst protection (default: 60 requests/minute)
* **Per-hour limits**: Quota enforcement (default: 2,000 requests/hour)
Rate limits can be customized per API key or per company based on your subscription plan.
## How It Works
Our rate limiting system provides:
* **Fair enforcement**: Smooth limit enforcement that prevents burst abuse
* **High performance**: Minimal overhead (\~2-5ms per request)
* **High availability**: System stays available even during technical issues
* **Full visibility**: Rate limit information in every response header
## Response Headers
All API responses include rate limit information in HTTP headers:
```http theme={null}
X-RateLimit-Limit-Minute: 60
X-RateLimit-Limit-Hour: 2000
X-RateLimit-Remaining-Minute: 45
X-RateLimit-Remaining-Hour: 1850
X-RateLimit-Reset-Minute: 1705320120
X-RateLimit-Reset-Hour: 1705323720
```
**Header Descriptions:**
* `X-RateLimit-Limit-Minute`: Maximum requests allowed per minute
* `X-RateLimit-Limit-Hour`: Maximum requests allowed per hour
* `X-RateLimit-Remaining-Minute`: Requests remaining this minute
* `X-RateLimit-Remaining-Hour`: Requests remaining this hour
* `X-RateLimit-Reset-Minute`: Unix timestamp when minute limit resets
* `X-RateLimit-Reset-Hour`: Unix timestamp when hour limit resets
## Rate Limit Response
When you exceed the rate limit, you'll receive:
```http theme={null}
HTTP/1.1 429 Too Many Requests
Retry-After: 60
```
```json theme={null}
{
"statusCode": 429,
"message": "Rate limit exceeded",
"error": "Too Many Requests",
"retryAfter": 30,
"limit": 60,
"remaining": 0,
"reset": 1705320120
}
```
**Response Fields:**
* `retryAfter`: Seconds to wait before retrying
* `limit`: The limit that was exceeded
* `remaining`: Always 0 when rate limited
* `reset`: Unix timestamp when the limit resets
## Subscription Plan Limits
Rate limits are automatically set based on your subscription plan:
| Plan | Requests/Minute | Requests/Hour | Burst Size |
| - | - | - | - |
| **Trial** | 30 | 500 | 50 |
| **Starter** | 120 | 5,000 | 200 |
| **Business** | 300 | 15,000 | 500 |
| **Scale** | 600 | 30,000 | 1,000 |
| **Enterprise** | Custom | Custom | Custom |
Rate limits follow your active subscription. Ask your account manager to upgrade your plan or to arrange custom limits.
## Endpoints With Lower Limits
A few endpoints do more than read or write a record, and carry their own tighter budget on top of your plan's limits. Staying inside your plan's allowance is not enough for these — the narrower limit applies first.
| Endpoint | Requests/Minute | Requests/Hour |
| - | - | - |
| `POST /agents/{id}/knowledge` | 6 | 60 |
| `POST /agents/{id}/knowledge/{documentId}/complete` | 20 | 200 |
Starting a knowledge upload reserves a document slot and, once completed, a stored file — so it is limited well below ordinary traffic. Completing an upload is limited more generously, because retrying a failed completion should be cheap.
These responses carry a `Retry-After` header with the number of seconds to
wait. They do not consume a different budget from your plan limits — they are
an additional ceiling, not a separate allowance.
## Best Practices
Implement request queuing in your application to avoid overwhelming the API:
```javascript theme={null}
class RequestQueue {
constructor(concurrency = 5) {
this.concurrency = concurrency;
this.running = 0;
this.queue = [];
}
async add(fn) {
while (this.running >= this.concurrency) {
await new Promise(resolve => this.queue.push(resolve));
}
this.running++;
try {
return await fn();
} finally {
this.running--;
const next = this.queue.shift();
if (next) next();
}
}
}
// Usage
const queue = new RequestQueue(5);
await queue.add(() => fetch('/api/endpoint'));
```
Cache frequently accessed data to reduce API calls:
```javascript theme={null}
class APIClient {
constructor(apiKey, cacheTTL = 60000) {
this.apiKey = apiKey;
this.cache = new Map();
this.cacheTTL = cacheTTL;
}
async get(url) {
const cached = this.cache.get(url);
if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
return cached.data;
}
const response = await fetch(url, {
headers: { 'Authorization': `Bearer ${this.apiKey}` }
});
const data = await response.json();
this.cache.set(url, { data, timestamp: Date.now() });
return data;
}
}
```
When possible, batch multiple operations together:
```javascript theme={null}
// ❌ Multiple individual requests
for (const contact of contacts) {
await api.post('/contacts', contact);
}
// ✅ Better: batch, and pace the batches against your actual per-minute limit.
// Pick a fixed batchSize/delay pair and you are guessing: 10 requests every 100ms is
// 6,000 a minute, which is ten times the Scale limit and two hundred times Trial's.
// Default to the lowest plan and let the response headers raise it.
async function processBatch(
items,
{ batchSize = 5, requestsPerMinute = 30 } = {}
) {
let limitPerMinute = requestsPerMinute;
for (let i = 0; i < items.length; i += batchSize) {
const startedAt = Date.now();
const batch = items.slice(i, i + batchSize);
const responses = await Promise.all(
batch.map(item => api.post('/contacts', item))
);
// The API states its own ceiling on every response — trust it over the default.
const advertised = Number(
responses[0]?.headers?.get('X-RateLimit-Limit-Minute')
);
if (Number.isFinite(advertised) && advertised > 0) {
limitPerMinute = advertised;
}
// A 429 on any request in the batch overrides the pacing entirely.
const retryAfterMs = Math.max(
0,
...responses.map(r => Number(r?.headers?.get('Retry-After')) * 1000 || 0)
);
if (i + batchSize >= items.length) break;
const minIntervalMs = (60_000 / limitPerMinute) * batchSize;
const waitMs = Math.max(
retryAfterMs,
minIntervalMs - (Date.now() - startedAt)
);
if (waitMs > 0) {
await new Promise(resolve => setTimeout(resolve, waitMs));
}
}
}
```
Add small delays between requests to avoid bursts:
```javascript theme={null}
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function processWithDelay(items, delayMs = 200) {
const results = [];
for (const item of items) {
results.push(await processItem(item));
await sleep(delayMs);
}
return results;
}
```
Be prepared for potential errors with exponential backoff:
```javascript theme={null}
async function exponentialBackoff(fn, maxRetries = 5) {
let delay = 1000;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, delay));
delay *= 2;
}
}
}
```
## Monitoring Your Usage
Use the response headers to monitor your API usage:
```javascript theme={null}
const response = await fetch("/api/endpoint", {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
const minuteLimit = response.headers.get("X-RateLimit-Limit-Minute");
const minuteRemaining = response.headers.get("X-RateLimit-Remaining-Minute");
const minuteReset = response.headers.get("X-RateLimit-Reset-Minute");
console.log(`Rate limit: ${minuteRemaining}/${minuteLimit} remaining`);
console.log(`Resets at: ${new Date(minuteReset * 1000).toISOString()}`);
```
## Handling Rate Limits
Implement exponential backoff when you receive a 429 response:
```javascript theme={null}
async function makeRequestWithRetry(url, options, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status === 429) {
const retryAfter =
response.headers.get("Retry-After") || Math.pow(2, attempt); // Exponential backoff
console.log(`Rate limited. Retrying in ${retryAfter}s...`);
await sleep(retryAfter * 1000);
continue;
}
return response;
}
throw new Error("Max retries exceeded");
}
```
## Custom Rate Limits
If you need custom rate limits for your use case:
Discuss your specific requirements
Share expected request volumes and patterns
We'll configure custom limits for your API keys
Test with your new limits before going live
## Getting in Touch
If you have specific rate limit requirements or questions:
* Email: [support@instaview.sk](mailto:support@instaview.sk)
* Enterprise customers: ask your account manager
## Next Steps
Learn how to handle all API errors
Production integration patterns
Efficiently paginate large result sets
Understand API key authentication
# Agents
Source: https://docs.instaview.sk/guides/resources/agents
Configure the agents that run your calls
## Overview
Agents are AI-powered callers that conduct conversations on your behalf. Each agent has a voice, a language and a duration, and there are two ways to design what it actually says:
* **Pick a `focus`** and supply `questions` — a hiring template, described below. This is the original model, which is why an agent is sometimes still called an interview template.
* **Send a `flow`** and design the conversation yourself — a [custom agent](#custom-agents). Not hiring-specific, and what to reach for outside recruiting.
## Resource Structure
```jsonc theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Technical Screening Agent",
"instructions": "Evaluates technical skills for software engineering roles",
"voiceId": "ALEX",
"type": "ONLINE",
"focus": "SCREENING",
"companyPhoneNumberId": "123e4567-e89b-12d3-a456-426614174000", // Required if type is PHONE
"backgroundSound": "OFFICE", // "OFFICE" or "OFF" — applies to all agent types
"questions": [
"Tell me about your experience with React and modern frontend development",
"Explain how you would design a scalable microservices architecture",
"Describe a challenging technical problem you solved recently",
],
"duration": 30, // in minutes (max 180)
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z",
}
```
## Creating Agents
```javascript theme={null}
const agent = await fetch("https://api.instaview.sk/agents", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INSTAVIEW_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Senior Frontend Developer",
voiceId: "ALEX",
type: "ONLINE",
focus: "SCREENING",
questions: [
"Can you describe your experience with React?",
"How do you optimize for performance?",
],
duration: 30,
}),
});
```
**Best Practice**: For production integrations, always implement request timeouts.
```javascript theme={null}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
try {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
```
## Voice Options
| Voice | Gender | Description |
| - | - | - |
| `ALEX` | Male | Clear, neutral, business-appropriate (English) |
| `PETER` | Male | Professional, executive tone (English) |
| `MIRIAM` | Female | Professional, clear (English) |
| `SUE` | Female | Warm, approachable tone (localized as Zuzka) |
| `VIERA` | Female | Clear, articulate (Slovak/Czech optimized) |
| `CASANDRA` | Female | Professional, clear |
| `SILVIA` | Female | Clear, neutral |
| `MICHAEL` | Male | Professional (localized as Michal) |
| `LUKE` | Male | Young, energetic (localized as Lukáš) |
| `EMMA` | Female | Warm, friendly (localized as Ema) |
| `SARAH` | Female | Professional, clear (localized as Sára) |
| `EVA` | Female | Clear, articulate |
**Duration**: The `duration` field is specified in minutes. The maximum
allowed duration is 180 minutes (3 hours).
## Background Sound
`backgroundSound` controls the ambient noise played to the contact during calls. It can be set on create or updated at any time via `PATCH /agents/{id}`.
| Value | Description |
| - | - |
| `OFFICE` | Subtle office ambient noise (default) |
| `OFF` | Complete silence |
## Speaking for Another Company
An agent introduces itself as the company your API key belongs to. If you call on behalf of your own customers, set `overrides` on the agent and it speaks as that customer instead:
```jsonc theme={null}
{
"overrides": {
"companyName": "Acme Manufacturing",
"companyDescription": "Acme makes industrial fasteners and employs 400 people across Slovakia.",
},
}
```
Both fields are optional and each falls back to your company profile, so agents without `overrides` are unchanged. It works on template and custom agents alike.
**Send both fields together.** They fall back independently, so a `companyName` on its own
leaves your own company's description in place and the agent introduces itself as Acme
while describing you. `PATCH` also replaces the object whole rather than merging it, so
send every field you want kept.
Only what the agent *says* changes: ownership, billing, analytics and phone-number routing all keep your real company.
## Agent Focus Options
`focus` picks one of four hiring templates, each with its own style of call:
A first hiring screen - Basic qualifications - Availability - Salary
expectations
Proactive outreach - Role introduction - Initial interest check
* Company presentation
General purpose, job-optional - Flexible questioning - adaptable to various
contexts
Language proficiency assessment - Vocabulary and grammar - Fluency
verification - Comprehension check
## Custom Agents
The four focus options above are hiring templates: you supply questions, InstaView supplies the rest of the call. A **custom agent** is the other way round — you design the whole conversation yourself by sending a `flow` instead of a `focus`, and the agent runs what you designed.
This is what makes an agent usable outside hiring: sales discovery, support callbacks, renewal check-ins, operations checks. Nothing about a custom agent is recruiting-specific.
An agent is custom the moment it carries a `flow`. You never send `focus: "CUSTOM"` — it is derived, and sending both is rejected.
```javascript theme={null}
{
"name": "Renewal check-in",
"type": "PHONE",
"language": "EN",
"duration": 10,
"companyPhoneNumberId": "123e4567-e89b-12d3-a456-426614174000",
"flow": {
"firstMessage": { "type": "first_message", "text": "Hi {{contact.first_name}}, do you have two minutes?" },
"sections": [
{ "type": "sequential", "label": "Intro", "prompt": "Say why you are calling." },
{ "type": "looping", "questions": [
{ "question": "How has the setup been working?", "idealAnswer": "No outstanding problems" }
]}
],
"lastMessage": { "type": "last_message", "text": "Thanks for your time!" }
}
}
```
### The four pieces
| Field | What it is |
| - | - |
| `flow` | The conversation itself — a `firstMessage`, one or more `sections`, a `lastMessage`. Sections are `sequential` (a topic, as a free-text prompt), `looping` (questions, each with the answer it is scored against), `conditional` (branches taken on intent) or `human_handoff` (transfer the call to a person, PHONE agents only — the conversation ends there). |
| `guardrails` | Things the agent must always or never do — "never quote a price". |
| `contextConfig` | Who the agent is: `role`, `communicationStyle`, `callToAction`. |
| `analyticsConfig` | What should come back **after** the call — see below. |
The last three are accepted **only** alongside a `flow`; sending them for a template agent is a `422`.
**You do not describe your company in `contextConfig`.** The description on your company
profile is given to every custom agent automatically and resolved at the moment of each
call, so editing the profile reaches the next call without touching a single agent.
**`instructions` is where reference material belongs.** It is not one of the four above —
a template agent takes it too — and it is compiled into the agent's prompt as its own
reference section. Put the facts the agent has to state correctly there: prices, stock,
lead times, opening hours, policy. The agent is told never to invent a figure, so anything
you leave out is something it will say it does not have rather than guess.
### Asking for a person
A `human_handoff` section transfers the call, and the agent will also honour a request for a
person made **anywhere else** in the call: as soon as somebody says they would rather speak
to a human, the agent says one short line and transfers, instead of carrying on with your
flow. It picks between destinations by their `label`, which is why a flow that can reach two
different numbers has to label each of them.
An agent whose flow has **no** `human_handoff` has nothing to transfer to. Asked for a
person, it says a colleague will be in touch and ends the call.
### The first message always discloses the AI
Article 50 of the EU AI Act requires a person to be told they are interacting with an AI
system, unless that is already obvious to them, and the duty falls on us as the provider
rather than on you. **We apply the disclosure unconditionally**, without judging whether
any given call would qualify as obvious, because that judgement is not one a platform can
make per call and the person on the other end is entitled to know either way.
So if your `flow.firstMessage` does not already say so, one short sentence is appended to
it at call time, in the agent's own language. **Every language `language` accepts has its
own sentence**, so the notice is always in the language the call is held in:
| `language` | Appended | `language` | Appended |
| - | - | - | - |
| `SK` | `Som virtuálny hlasový asistent.` | `EL` | `Είμαι ένας φωνητικός βοηθός τεχνητής νοημοσύνης.` |
| `CS` | `Jsem virtuální hlasový asistent.` | `JA` | `私はAI音声アシスタントです。` |
| `EN` | `I'm an AI voice assistant.` | `ZH` | `我是人工智能语音助手。` |
| `DE` | `Ich bin ein KI-Sprachassistent.` | `KO` | `저는 AI 음성 비서입니다.` |
| `NL` | `Ik ben een AI-spraakassistent.` | `HI` | `मैं एक AI वॉइस असिस्टेंट हूँ।` |
| `FR` | `Je suis un assistant vocal IA.` | `TA` | `நான் ஒரு AI குரல் உதவியாளர்.` |
| `ES` | `Soy un asistente de voz con IA.` | `AR` | `أنا مساعد صوتي يعمل بالذكاء الاصطناعي.` |
| `IT` | `Sono un assistente vocale IA.` | `RU` | `Я голосовой помощник с искусственным интеллектом.` |
| `PT` | `Sou um assistente de voz com IA.` | `UK` | `Я голосовий помічник зі штучним інтелектом.` |
| `PL` | `Jestem asystentem głosowym AI.` | `BG` | `Аз съм гласов асистент с изкуствен интелект.` |
| `HU` | `AI hangasszisztens vagyok.` | `ID` | `Saya adalah asisten suara AI.` |
| `RO` | `Sunt un asistent vocal AI.` | `MS` | `Saya ialah pembantu suara AI.` |
| `HR` | `Ja sam AI glasovni asistent.` | `FIL` | `Ako ay isang AI voice assistant.` |
| `SV` | `Jag är en AI-röstassistent.` | `VI` | `Tôi là trợ lý giọng nói AI.` |
| `DA` | `Jeg er en AI-stemmeassistent.` | `FI` | `Olen tekoälyn puheavustaja.` |
| `NO` | `Jeg er en AI-stemmeassistent.` | `TR` | `Ben bir yapay zekâ sesli asistanıyım.` |
```javascript theme={null}
// Sent:
{ "firstMessage": { "type": "first_message", "text": "Dobrý deň, tu je Jana zo spoločnosti Acme." } }
// Spoken:
// "Dobrý deň, tu je Jana zo spoločnosti Acme. Som virtuálny hlasový asistent."
```
Nothing is appended when your opening already discloses. We look for the artificial part,
not the assistant part, in every language in the table: `virtuálny` / `virtuální`,
`umelá inteligencia` / `umělá inteligence`, `automatizovaný`, `hlasový asistent`, a
standalone `AI`, `artificial intelligence`, `virtual assistant`, `automated`,
`künstliche Intelligenz`, `sztuczna inteligencja`, `kecerdasan buatan`, `人工知能`, and
similar. A bare "assistant" or "asistent" does **not** count, because a company assistant
who is a human being is an ordinary thing to be. Nor does `AI` sitting inside a company
name: an opening that says "calling from AI Talent Partners" and nothing else has not
disclosed, and will still get the sentence.
Your stored `firstMessage` is never rewritten: the sentence is added on the way to the call,
and reading the agent back returns exactly what you sent. **To control the wording yourself,
write your own disclosure into `firstMessage`** and ours will not be added.
### Getting structured data back
Without `analyticsConfig`, a custom agent places a call and returns a transcript. With it, you get data your own systems can act on — the difference between "here is what was said" and "budget confirmed: true, meeting booked: yes".
```javascript theme={null}
"analyticsConfig": {
"extractionTargets": [
{ "key": "budget_confirmed", "label": "Budget confirmed", "type": "bool",
"ideal": "A confirmed budget of at least 10k", "importance": "REQUIRED", "weight": 50 }
],
"scoringCriteria": [{ "label": "Handled objections", "description": "…", "weight": 20 }],
"outcomes": [{ "label": "Meeting booked", "description": "Agreed a specific day" }],
"outputTags": [{ "label": "Needs follow-up", "description": "Asked to be called back" }]
}
```
* **`extractionTargets`** — values pulled out of every call. One with an `ideal` also counts toward the match score.
* **`scoringCriteria`** — holistic judgements over the whole call.
* **`outcomes`** — did the call achieve what it was for? Decided met / not met.
* **`outputTags`** — labels the AI may apply.
* **`capture`** — which qualitative artefacts to produce (summary, Q\&A, evaluation, sentiment, recording, transcript). All default to `true`.
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` — convenient, but renaming the label later silently moves the results to a new key. Set it when your integration reads results by name.
Results arrive as `analytics` on the conversation and on the `analysis.completed` webhook. See [Conversations](/guides/resources/conversations#custom-agent-analytics).
**Scoring a flow question is set inline on the question**, not in `analyticsConfig` — add
`importance` and `weight` to the question itself. Questions are addressed by ids InstaView
mints, which you cannot know while writing the flow. A question with an `idealAnswer` but
no `importance` or `weight` is asked but **not** scored, and `idealAnswer` itself is optional —
omit it for a question that has no right answer.
### Personalising each call
Any text in a flow may contain `{{contact.}}` placeholders. They are resolved per call, not when the agent is created, so one agent serves everyone you call. `first_name`, `last_name`, `email`, `phone`, `company_name`, `company_description` and `agent_name` are always available; add your own by sending them as the contact's `fields`.
Full flow schema, validation errors, key rules and analytics configuration
## Knowledge Base
Any agent — template or custom — can be given documents to consult mid-call: a job spec, a benefits summary, a pricing sheet, an FAQ. The agent looks them up on its own when the conversation calls for it; you do not have to reference them anywhere in the flow.
Attaching one is a three-step flow, because the file goes straight to storage rather than through this API:
```bash theme={null}
# 1. Reserve a slot and get an upload URL
curl -X POST https://api.instaview.sk/agents/$AGENT_ID/knowledge \
-H "Authorization: Bearer $INSTAVIEW_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "fileName": "benefits.pdf", "mimeType": "application/pdf", "sizeBytes": 482133 }'
# 2. PUT the bytes to the returned uploadUrl — no API key on this request
curl -X PUT "$UPLOAD_URL" -H "Content-Type: application/pdf" --data-binary @benefits.pdf
# 3. Confirm, which verifies the stored file and makes the document usable
curl -X POST https://api.instaview.sk/agents/$AGENT_ID/knowledge/$DOCUMENT_ID/complete \
-H "Authorization: Bearer $INSTAVIEW_API_KEY"
```
PDF, DOC/DOCX, TXT, MD, CSV, TSV, JSON, XML and YAML are accepted, up to 20 MB and 10 documents per agent. Documents can be deactivated to park them without losing them, and deleting one erases the file for good.
Uploads are rate limited more tightly than the rest of the API. Reserve slots as you need
them rather than in a burst — see the endpoint reference for the current numbers.
The full upload flow, limits, verification rules and error cases
## Best Practices
Write specific, open-ended questions
15-30 minutes for most calls
Test agents before using in production
Refine based on how the calls go
## Next Steps
See how a conversation uses an agent
View complete Agents API reference
# Billing & Usage
Source: https://docs.instaview.sk/guides/resources/billing
Monitor usage and track call minutes or credits
## Overview
The Billing API provides read-only access to your usage statistics. InstaView supports two billing systems and the same endpoint serves both:
* **Minutes** *(legacy)* — companies are billed by call minutes consumed. Subscription plans include a minutes allowance and an overage rate per minute.
* **Credits** *(new)* — companies are billed in credits, with a per-product price (`baseCost` plus `costPerMinute` beyond an `includedMinutes` window) determined by the agent focus.
**Required Scope**: `read:billing` is required to access billing and usage endpoints.
## Usage Endpoint
### Get Usage Statistics
```javascript theme={null}
const response = await fetch(
'https://api.instaview.sk/billing/usage?start=2024-01-01&end=2024-01-31',
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
const data = await response.json();
```
### Query Parameters
* `start` (optional): Start date for the usage period (ISO 8601 format)
* `end` (optional): End date for the usage period (ISO 8601 format)
* If not provided, defaults to the last 30 days
### Response Structure (Minutes-Based Company)
```json theme={null}
{
"companies": [
{
"companyId": "550e8400-e29b-41d4-a716-446655440000",
"companyName": "Acme Corp",
"billingSystem": "minutes",
"totalMinutesUsed": 245.5,
"totalInterviews": 42,
"breakdownByCostKey": [
{
"costKey": "interview_minutes",
"minutesUsed": 245.5,
"events": 42
}
]
}
],
"totalMinutesUsed": 245.5,
"totalInterviews": 42,
"breakdownByCostKey": [
{
"costKey": "interview_minutes",
"minutesUsed": 245.5,
"events": 42
}
]
}
```
### Response Structure (Credits-Based Company)
```json theme={null}
{
"companies": [
{
"companyId": "550e8400-e29b-41d4-a716-446655440000",
"companyName": "Acme Corp",
"billingSystem": "credits",
"totalCreditsUsed": 612.5,
"totalInterviews": 42,
"breakdownByCostKey": [
{
"costKey": "interview_minutes",
"creditsUsed": 612.5,
"events": 42
}
]
}
],
"totalCreditsUsed": 612.5,
"totalInterviews": 42,
"breakdownByCostKey": [
{
"costKey": "interview_minutes",
"creditsUsed": 612.5,
"events": 42
}
]
}
```
### Field Descriptions
* **companies**: Array of company-level usage (for ATS keys managing multiple companies, this contains all companies)
* **billingSystem** *(per company)*: `"minutes"` or `"credits"`. Determines which usage fields are populated.
* **totalMinutesUsed**: Total call minutes consumed in the period. Present for minutes-based companies (and for ATS keys, aggregated across the managed companies); omitted otherwise.
* **totalCreditsUsed**: Total credits consumed in the period. Present for credits-based companies; omitted otherwise.
* **totalInterviews**: How many conversations ran. The field keeps its original name, as does the `interview_minutes` cost key — see [Resource names](/api-reference/introduction#resource-names).
* **breakdownByCostKey**: Usage breakdown by cost category. Each entry exposes `costKey`, `events`, and either `minutesUsed` (minutes-based) or `creditsUsed` (credits-based). Per-company breakdowns never mix the two units.
## Subscription Plans
Subscription plans are defined by:
* **maxInterviewMinutes**: Call minutes included in the plan (minutes-based companies)
* **pricePerMonth**: Monthly subscription cost
* **pricePerYear**: Annual subscription cost (if applicable)
* **pricePerMinute**: Per-minute cost for usage beyond included minutes (minutes-based companies)
* **Feature flags**: Various boolean flags for features like custom voices, phone numbers, ATS integrations, etc.
For credits-based companies, the price of a call is governed by per-product **credit pricing rules** (a `baseCost`, `includedMinutes`, and `costPerMinute` per agent focus). Custom overrides may apply per company.
Plan details, credit pricing rules, and any custom overrides are managed through your company dashboard. Talk to sales about custom pricing.
## Monitoring Usage
### Check Period Usage
```javascript theme={null}
async function checkUsageStatus() {
const usage = await getBillingUsage();
for (const company of usage.companies) {
if (company.billingSystem === "credits") {
console.log(`${company.companyName}: ${company.totalCreditsUsed} credits used`);
} else {
console.log(`${company.companyName}: ${company.totalMinutesUsed.toFixed(1)} minutes used`);
}
console.log(` Total conversations: ${company.totalInterviews}`);
}
return usage;
}
```
### Usage Alerts
Set up monitoring to track usage patterns:
```javascript theme={null}
async function monitorUsage() {
const usage = await fetch(
'https://api.instaview.sk/billing/usage',
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
).then(r => r.json());
// `totalMinutesUsed` is absent on a credits-based company, and `totalInterviews` is 0 in a
// period with no calls. Dividing without checking both prints `NaN` as a call length.
const avgMinutesPerCall =
usage.totalMinutesUsed != null && usage.totalInterviews > 0
? usage.totalMinutesUsed / usage.totalInterviews
: null;
if (avgMinutesPerCall !== null) {
console.log(`Average call length: ${avgMinutesPerCall.toFixed(1)} minutes`);
}
// Set up alerts based on your plan limits
if (usage.totalMinutesUsed > 1000) {
console.warn('High usage detected - consider reviewing subscription plan');
}
}
```
## ATS Integration Keys
For ATS integration keys managing multiple companies, the usage endpoint returns aggregated data across all managed companies:
```json theme={null}
{
"companies": [
{
"companyId": "company-a-uuid",
"companyName": "Company A",
"billingSystem": "minutes",
"totalMinutesUsed": 120.0,
"totalInterviews": 20
},
{
"companyId": "company-b-uuid",
"companyName": "Company B",
"billingSystem": "minutes",
"totalMinutesUsed": 125.5,
"totalInterviews": 22
}
],
"totalMinutesUsed": 245.5,
"totalInterviews": 42,
"breakdownByCostKey": [...]
}
```
## Usage Reporting
### Monthly Usage Report
```javascript theme={null}
async function getMonthlyReport(year, month) {
const start = new Date(year, month - 1, 1).toISOString();
const end = new Date(year, month, 0, 23, 59, 59).toISOString();
const response = await fetch(
`https://api.instaview.sk/billing/usage?start=${start}&end=${end}`,
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
const data = await response.json();
return {
period: `${year}-${String(month).padStart(2, '0')}`,
minutes: data.totalMinutesUsed,
conversations: data.totalInterviews,
avgMinutesPerCall: data.totalMinutesUsed / data.totalInterviews
};
}
// Usage
const report = await getMonthlyReport(2024, 1);
console.log(`January 2024: ${report.minutes} minutes, ${report.conversations} conversations`);
```
### Date Range Analysis
```javascript theme={null}
async function analyzeUsageTrend(startDate, endDate, intervalDays = 7) {
const reports = [];
const current = new Date(startDate);
const end = new Date(endDate);
while (current <= end) {
const intervalEnd = new Date(current);
intervalEnd.setDate(intervalEnd.getDate() + intervalDays);
const response = await fetch(
`https://api.instaview.sk/billing/usage?start=${current.toISOString()}&end=${intervalEnd.toISOString()}`,
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
const data = await response.json();
reports.push({
start: current.toISOString(),
end: intervalEnd.toISOString(),
minutes: data.totalMinutesUsed,
conversations: data.totalInterviews
});
current.setDate(current.getDate() + intervalDays);
}
return reports;
}
```
## Cost Breakdown
The `breakdownByCostKey` field provides granular usage tracking. Each bucket carries `minutesUsed` for minutes-based companies and `creditsUsed` for credits-based companies.
```javascript theme={null}
async function getCostBreakdown() {
const usage = await getBillingUsage();
for (const breakdown of usage.breakdownByCostKey) {
console.log(`${breakdown.costKey}:`);
if (typeof breakdown.creditsUsed === "number") {
console.log(` - Credits: ${breakdown.creditsUsed}`);
console.log(` - Avg per event: ${(breakdown.creditsUsed / breakdown.events).toFixed(2)} credits`);
}
if (typeof breakdown.minutesUsed === "number") {
console.log(` - Minutes: ${breakdown.minutesUsed}`);
console.log(` - Avg per event: ${(breakdown.minutesUsed / breakdown.events).toFixed(2)} min`);
}
console.log(` - Events: ${breakdown.events}`);
}
}
```
## Best Practices
Cache usage statistics to reduce API calls:
```javascript theme={null}
class UsageCache {
constructor(ttl = 300000) { // 5 minutes
this.cache = null;
this.lastFetch = 0;
this.ttl = ttl;
}
async get() {
if (this.cache && Date.now() - this.lastFetch < this.ttl) {
return this.cache;
}
this.cache = await fetchUsageData();
this.lastFetch = Date.now();
return this.cache;
}
}
```
Monitor usage and set up alerts:
```javascript theme={null}
async function checkUsageAlerts(thresholds) {
const usage = await getBillingUsage();
if (usage.totalMinutesUsed > thresholds.warning) {
sendAlert('warning', `Usage at ${usage.totalMinutesUsed} minutes`);
}
if (usage.totalMinutesUsed > thresholds.critical) {
sendAlert('critical', `Usage exceeded ${thresholds.critical} minutes`);
}
}
```
For ATS integrations, monitor usage per company:
```javascript theme={null}
async function trackCompanyUsage() {
const usage = await getBillingUsage();
const sortedCompanies = usage.companies.sort(
(a, b) => b.totalMinutesUsed - a.totalMinutesUsed
);
console.log('Top companies by usage:');
sortedCompanies.slice(0, 10).forEach(company => {
console.log(`${company.companyName}: ${company.totalMinutesUsed} min`);
});
}
```
## Managing Subscriptions
**Subscription changes must be made through the dashboard.** The API gives read-only access to usage data. Ask your account manager, or use the billing section of your dashboard, to change your plan.
For subscription changes:
1. Log in to [app.instaview.sk](https://app.instaview.sk)
2. Navigate to **Settings** → **Billing**
3. View available plans and make changes
4. Or email sales: [sales@instaview.sk](mailto:sales@instaview.sk)
## Next Steps
Schedule and manage calls
Optimize your API usage
Handle billing errors gracefully
Understand API key scopes
# Companies
Source: https://docs.instaview.sk/guides/resources/companies
Manage company profiles and settings
## Overview
The Companies API allows you to view and update your company profile. Regular API keys can only access their associated company, while ATS integration keys can manage multiple companies.
## Resource Structure
```json theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Acme Corporation",
"website": "https://acme.com",
"industry": "Technology",
"size": "51-200",
"location": {
"city": "San Francisco",
"countryCode": "US"
},
"settings": {
"timezone": "America/Los_Angeles",
"locale": "en-US"
},
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
```
## Required Scopes
| Operation | Required Scope |
| - | - |
| Get company | `read:companies` |
| Update company | `write:companies` |
## Retrieving Company
```javascript theme={null}
const response = await fetch(
`https://api.instaview.sk/companies/${companyId}`,
{ headers: { 'Authorization': `Bearer ${apiKey}` } }
);
const data = await response.json();
```
## Updating Company
```javascript theme={null}
await fetch(`https://api.instaview.sk/companies/${companyId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'Acme Corporation',
website: 'https://acme.com',
industry: 'Technology'
})
});
```
## ATS Integration
ATS keys can create and manage multiple companies:
```javascript theme={null}
// Create a new company (ATS keys only)
const company = await fetch('https://api.instaview.sk/companies', {
method: 'POST',
headers: {
'Authorization': `Bearer ${atsApiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'Client Company',
website: 'https://client.com'
})
});
```
## Next Steps
Learn about ATS integration keys
View complete Companies API reference
# Contacts
Source: https://docs.instaview.sk/guides/resources/contacts
Manage the people your agents call, with the Contacts API
This resource is also `/contacts`. `/candidates` is a permanent alias that keeps working unchanged; the newer name is the one to reach for in new code. See [Resource names](/api-reference/introduction#resource-names).
## Overview
A contact is the person on the other end of a conversation. The Contacts API creates them, keeps them up to date, and finds them again. A contact may be associated with one or more jobs, or with none at all — outside hiring, none is the normal case.
## Resource Structure
```json theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"jobId": "987e6543-e21b-12d3-a456-426614174000",
"jobIds": ["987e6543-e21b-12d3-a456-426614174000"],
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com",
"phoneNumber": "+421915123456",
"status": "APPLIED",
"gender": "female",
"gdprExpiryDate": "2026-11-16",
"anonymizedCvText": "[NAME]\nSoftware Engineer\nExperience: ...",
"overallRating": 85,
"metadata": {
"source": "LinkedIn",
"externalId": "CAND-12345",
"resumeUrl": "https://storage.example.com/resumes/jane-doe.pdf",
"linkedinUrl": "https://linkedin.com/in/janedoe"
},
"fields": {
"order_number": "SO-40128",
"is_vip": true
},
"workHistory": [
{
"id": "work-history-uuid",
"companyName": "Tech Corp",
"candidatePosition": "Software Engineer",
"referenceName": "Jane Smith",
"referencePhone": "+1234567890",
"startDate": "2020-01-01",
"endDate": "2022-12-31"
}
],
"createdAt": "2025-11-20T10:30:00Z",
"updatedAt": "2025-11-20T10:30:00Z"
}
```
**Free-form metadata**: anything you want to keep about a contact for your own use — resume
URL, LinkedIn profile, skills — goes in `metadata` as key-value pairs. It is never
validated and never read by an agent.
**Custom fields are a different thing**, and they are the half an agent can speak: values
defined in your company catalog, validated on write, and addressable as
`{{contact.}}`. Those live in `fields` — see
[Update Contact](/api-reference/contacts/update-contact).
The contact schema also declares `analysisCount`, `interviewCount` and `links`, and they are
**not currently returned** by any endpoint — nothing populates them. They are left out of the
structure above deliberately; treat them as absent rather than as zero, and count a contact's
conversations with [List Conversations](/api-reference/conversations/list-conversations).
## Required Scopes
| Operation | Required Scope | Additional Scopes |
| - | - | - |
| List contacts | `read:contacts` | `read:jobs` (for filtering) |
| Get contact by ID | `read:contacts` | |
| Create contact | `write:contacts` | `read:jobs` (validation) |
| Update contact | `write:contacts` | |
| Delete contact | `delete:contacts` | |
The legacy aliases (`read:candidates`, `write:candidates`, `delete:candidates`) work identically and stay valid on keys that already carry them — see
[Scopes and Permissions](/guides/scopes-and-permissions).
## A Request Helper
Every JavaScript example below goes through this helper rather than calling `fetch` directly.
It does the two things a bare `fetch` does not: it gives the request a deadline, and it treats a
non-2xx answer as a failure. Without the first, a request to a stalled service never settles;
without the second, `response.json()` parses an error body and the code carries on as though it
had a contact.
```javascript theme={null}
async function apiFetch(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${apiKey}`,
...options.headers,
},
// 10s is a starting point; pick one from your own latency budget.
signal: options.signal ?? AbortSignal.timeout(10_000),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(
`${options.method ?? "GET"} ${url} failed: ${response.status} ${body.message ?? response.statusText}`,
);
}
return response;
}
```
The Python examples use `requests`, whose `timeout` argument is the equivalent — pass it on
every call, and check `response.raise_for_status()` before reading the body.
## Creating Contacts
### Basic Contact Creation
A contact can be created with or without a job. Without one, the contact simply exists on its own and jobs can be attached later.
```bash cURL (with job) theme={null}
curl -X POST https://api.instaview.sk/contacts \
-H "Authorization: Bearer sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"jobId": "job-uuid",
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com",
"phoneNumber": "+421915123456",
"gdprExpiryDate": "2026-11-16"
}'
```
```bash cURL (without job) theme={null}
curl -X POST https://api.instaview.sk/contacts \
-H "Authorization: Bearer sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com",
"phoneNumber": "+421915123456",
"gdprExpiryDate": "2026-11-16"
}'
```
```javascript Node.js (with job) theme={null}
const contact = await fetch("https://api.instaview.sk/contacts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INSTAVIEW_API_KEY}`,
"Content-Type": "application/json",
},
signal: AbortSignal.timeout(10_000),
body: JSON.stringify({
jobId: "job-uuid",
firstName: "Jane",
lastName: "Doe",
email: "jane.doe@example.com",
phoneNumber: "+421915123456",
gdprExpiryDate: "2026-11-16",
}),
});
if (!contact.ok) {
throw new Error(`Creating the contact failed: ${contact.status}`);
}
const result = await contact.json();
console.log("Contact created:", result.id);
```
```javascript Node.js (without job) theme={null}
const contact = await fetch("https://api.instaview.sk/contacts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INSTAVIEW_API_KEY}`,
"Content-Type": "application/json",
},
signal: AbortSignal.timeout(10_000),
body: JSON.stringify({
firstName: "Jane",
lastName: "Doe",
email: "jane.doe@example.com",
phoneNumber: "+421915123456",
gdprExpiryDate: "2026-11-16",
metadata: {
source: "Career Fair",
skills: ["Python", "JavaScript"],
},
}),
});
if (!contact.ok) {
throw new Error(`Creating the contact failed: ${contact.status}`);
}
const result = await contact.json();
console.log("Contact created:", result.id);
```
```python Python (with job) theme={null}
response = requests.post(
'https://api.instaview.sk/contacts',
headers={
'Authorization': f'Bearer {os.environ["INSTAVIEW_API_KEY"]}',
'Content-Type': 'application/json'
},
json={
'jobId': 'job-uuid',
'firstName': 'Jane',
'lastName': 'Doe',
'email': 'jane.doe@example.com',
'phoneNumber': '+421915123456',
'gdprExpiryDate': '2026-11-16'
},
timeout=10
)
response.raise_for_status()
contact = response.json()
print(f'Contact created: {contact["id"]}')
```
```python Python (without job) theme={null}
response = requests.post(
'https://api.instaview.sk/contacts',
headers={
'Authorization': f'Bearer {os.environ["INSTAVIEW_API_KEY"]}',
'Content-Type': 'application/json'
},
json={
'firstName': 'Jane',
'lastName': 'Doe',
'email': 'jane.doe@example.com',
'phoneNumber': '+421915123456',
'gdprExpiryDate': '2026-11-16'
},
timeout=10
)
response.raise_for_status()
contact = response.json()
print(f'Contact created: {contact["id"]}')
```
### A Fuller Contact
```javascript theme={null}
const fullContact = {
// Required fields
firstName: "Jane",
lastName: "Doe",
email: "jane.doe@example.com",
phoneNumber: "+1234567890",
gdprExpiryDate: "2026-11-16",
// Optional: Associate with job(s)
jobId: "job-uuid", // Single job association
// Store additional data in metadata (max 10KB)
metadata: {
// Professional information
yearsOfExperience: 5,
currentJobTitle: "Senior Software Engineer",
currentCompany: "Tech Corp",
skills: ["JavaScript", "React", "Node.js", "TypeScript", "AWS"],
// Education
educationLevel: "bachelors",
// Location
location: {
city: "San Francisco",
countryCode: "US",
},
// Availability
availability: {
canStart: "2024-03-01",
noticePeriod: 30, // days
},
// Links
resumeUrl: "https://storage.example.com/resumes/jane-doe.pdf",
linkedinUrl: "https://linkedin.com/in/janedoe",
portfolioUrl: "https://janedoe.dev",
// Additional context
notes: "Strong technical background with excellent communication skills",
},
};
const response = await createContact(fullContact);
```
## Listing Contacts
### List All Contacts
```javascript theme={null}
async function listContacts(page = 1, limit = 20) {
const response = await apiFetch(
`https://api.instaview.sk/contacts?page=${page}&limit=${limit}`,
);
// { data, total, page, limit, totalPages }
return await response.json();
}
// Usage
const { data, total } = await listContacts(1, 50);
console.log(`Found ${total} contacts, ${data.length} on this page`);
```
### Filter by Job
```javascript theme={null}
async function getContactsForJob(jobId) {
const response = await apiFetch(
`https://api.instaview.sk/contacts?jobId=${jobId}&limit=100`,
);
return await response.json();
}
```
### Filter by Status
```javascript theme={null}
async function getAppliedContacts() {
const response = await apiFetch(
"https://api.instaview.sk/contacts?status=APPLIED",
);
return await response.json();
}
```
### Search Contacts
```javascript theme={null}
async function searchContacts(query) {
const response = await apiFetch(
`https://api.instaview.sk/contacts?search=${encodeURIComponent(query)}`,
);
return await response.json();
}
// Search by name or email
const results = await searchContacts("jane doe");
```
## Updating Contacts
### Partial Update
```javascript theme={null}
async function updateContact(contactId, updates) {
const response = await apiFetch(
`https://api.instaview.sk/contacts/${contactId}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
},
);
return await response.json();
}
// Change how to reach them
await updateContact("contact-uuid", {
email: "jane.newemail@example.com",
phoneNumber: "+1987654321",
});
```
### Update Status
```javascript theme={null}
// Move the contact through your own process
await updateContact("contact-uuid", { status: "IN_PROCESS" });
await updateContact("contact-uuid", { status: "ACCEPTED" });
```
### Update Job Assignments
```javascript theme={null}
// Assign the contact to several jobs
await updateContact("contact-uuid", {
jobIds: ["job-uuid-1", "job-uuid-2", "job-uuid-3"],
});
// Add one more, keeping the existing assignments
const contact = await getContact("contact-uuid");
await updateContact("contact-uuid", {
jobIds: [...contact.jobIds, "new-job-uuid"],
});
// Remove every job assignment
await updateContact("contact-uuid", {
jobIds: [],
});
```
## Status Management
### Available Statuses
`status` is yours to move as your own process moves — InstaView does not act on it. The five
values predate the neutral vocabulary and keep their hiring names, because they are the values
the API accepts.
| Value | Typical meaning |
| - | - |
| `UNDEFINED` | No status set. |
| `APPLIED` | The starting point for an inbound contact. |
| `IN_PROCESS` | Somewhere in the middle of your process. |
| `REJECTED` | Closed out unsuccessfully. |
| `ACCEPTED` | Closed out successfully. |
### Status Workflow
```javascript theme={null}
class ContactWorkflow {
async startProcess(contactId) {
return await updateContact(contactId, {
status: "IN_PROCESS",
});
}
async accept(contactId, startDate) {
return await updateContact(contactId, {
status: "ACCEPTED",
metadata: {
startDate: startDate,
},
});
}
async reject(contactId, reason) {
return await updateContact(contactId, {
status: "REJECTED",
metadata: {
rejectionReason: reason,
},
});
}
}
```
## Multiple Job Assignments
A contact can be associated with several jobs through `jobIds`, so one person can be tracked across more than one opening.
```javascript theme={null}
// Create the contact with its first job assignment
const contact = await createContact({
firstName: "Jane",
lastName: "Doe",
email: "jane@example.com",
phoneNumber: "+1234567890",
gdprExpiryDate: "2026-11-16",
jobId: "job-1-uuid", // Initial job assignment
});
// Later, assign more
await updateContact(contact.id, {
jobIds: ["job-1-uuid", "job-2-uuid", "job-3-uuid"],
});
// The response carries both fields
// {
// "id": "contact-uuid",
// "jobId": "job-1-uuid", // First job (backward compatible)
// "jobIds": ["job-1-uuid", "job-2-uuid", "job-3-uuid"],
// ...
// }
```
**Job associations**: `jobIds` is the array to manage. `jobId` (singular) is kept for backward
compatibility and holds the first of them.
## Deleting Contacts
```javascript theme={null}
async function deleteContact(contactId) {
const response = await apiFetch(
`https://api.instaview.sk/contacts/${contactId}`,
{ method: "DELETE" },
);
return await response.json();
}
```
**This is a hard delete, and it cascades.** The contact goes, and so does every conversation
they had — transcripts, recordings, analyses — along with their job assignments. None of it is
readable through the API afterwards, and no endpoint brings it back. Export what you need
first. See [Delete Contact](/api-reference/contacts/delete-contact).
**GDPR**: this is the deletion the API offers, and the one to reach for on an erasure request.
Deleted rows are archived internally first, for our own compliance obligations, and that
archive is not reachable through the API — see [Delete
Contact](/api-reference/contacts/delete-contact) before promising a data subject that nothing
remains anywhere.
### Bulk Delete
```javascript theme={null}
async function bulkDeleteContacts(contactIds) {
const results = [];
for (const id of contactIds) {
try {
await deleteContact(id);
results.push({ id, success: true });
await sleep(200); // Rate limiting
} catch (error) {
results.push({ id, success: false, error: error.message });
}
}
return results;
}
```
## Common Patterns
### Importing Contacts from an ATS
```javascript theme={null}
async function importContactsFromATS(atsData, jobMapping) {
const imported = [];
const errors = [];
for (const atsRecord of atsData) {
try {
const instaviewJobId = jobMapping[atsRecord.jobId];
if (!instaviewJobId) {
throw new Error(`No mapping for job ${atsRecord.jobId}`);
}
const contact = await createContact({
jobId: instaviewJobId,
firstName: atsRecord.firstName,
lastName: atsRecord.lastName,
email: atsRecord.email,
phoneNumber: atsRecord.phone,
metadata: {
resumeUrl: atsRecord.resumeUrl,
},
status: mapAtsStatus(atsRecord.status),
});
imported.push(contact);
await sleep(200);
} catch (error) {
errors.push({
record: atsRecord,
error: error.message,
});
}
}
return { imported, errors };
}
function mapAtsStatus(atsStatus) {
const statusMap = {
new: "APPLIED",
in_review: "IN_PROCESS",
interview: "IN_PROCESS",
offer: "IN_PROCESS",
hired: "ACCEPTED",
rejected: "REJECTED",
};
return statusMap[atsStatus] || "APPLIED";
}
```
### Duplicate Detection
```javascript theme={null}
async function findDuplicates(contact) {
// Search by email
const byEmail = await searchContacts(contact.email);
// Search by phone
const byPhone = await searchContacts(contact.phoneNumber);
// Combine and deduplicate
const allMatches = [...byEmail.data, ...byPhone.data];
const uniqueMatches = Array.from(
new Map(allMatches.map((c) => [c.id, c])).values(),
);
return uniqueMatches.filter((c) => c.id !== contact.id);
}
async function createOrUpdateContact(contactData) {
const duplicates = await findDuplicates(contactData);
if (duplicates.length > 0) {
console.log(`Found ${duplicates.length} potential duplicates`);
// Update the one we already have
return await updateContact(duplicates[0].id, contactData);
}
return await createContact(contactData);
}
```
### Rolling Up a Contact's Calls
```javascript theme={null}
async function getContactSummary(contactId) {
const [contact, conversations] = await Promise.all([
getContact(contactId),
listConversations({ contactId }),
]);
return {
contact,
totalConversations: conversations.total,
completedConversations: conversations.data.filter(
(c) => c.status === "COMPLETED",
).length,
averageScore: calculateAverageScore(conversations.data),
lastConversationDate: getLastConversationDate(conversations.data),
};
}
// `overallRating` is null on a conversation we could not score, so test the type rather than
// the truthiness — a genuine 0 is a score, and null is the absence of one.
function calculateAverageScore(conversations) {
const scored = conversations.filter(
(c) => typeof c.analysis?.general?.overallRating === "number",
);
if (scored.length === 0) return null;
const sum = scored.reduce(
(acc, c) => acc + c.analysis.general.overallRating,
0,
);
return sum / scored.length;
}
```
### Resume Processing
```javascript theme={null}
async function uploadAndAttachResume(contactId, resumeFile) {
// 1. Upload resume to your storage
const resumeUrl = await uploadToStorage(resumeFile);
// 2. Record the URL on the contact
const updated = await updateContact(contactId, {
metadata: {
resumeUrl: resumeUrl,
},
});
// 3. Optional: Trigger resume parsing
await triggerResumeParser(contactId, resumeUrl);
return updated;
}
async function parseResumeData(resumeUrl) {
// Use a resume parsing service
const parsed = await resumeParsingService.parse(resumeUrl);
return {
skills: parsed.skills,
yearsOfExperience: parsed.totalYears,
educationLevel: parsed.highestDegree,
currentJobTitle: parsed.currentPosition,
currentCompany: parsed.currentEmployer,
};
}
```
## Validation Rules
First name (1-100 characters)
Last name (1-100 characters)
GDPR expiry date in ISO 8601 format (must be in the future, e.g.,
"2026-11-16")
Valid email address (at least email or phoneNumber required)
Phone number in E.164 format (+1234567890) (at least email or phoneNumber
required)
UUID of the job to associate with (optional - must exist and belong to your
company if provided)
Array of job UUIDs for multi-job assignments (used in update operations)
Custom key-value pairs for extensibility (max 10KB, max 5 levels deep, max 50
keys). Store additional fields like resumeUrl, skills, education, etc.
`"male"` or `"female"`. Used for gender-aware addressing on the call (Slovak and Czech formal
titles, for instance). Auto-detected from the name when omitted.
The contact's anonymized CV as plain text. Send the raw text as `cvText` on create or update
and it is anonymized for you using Google Gemini.
Previous employment, one entry per job. Each entry can carry a reference to call about it:
* **id** (string, optional): UUID of the work history record
* **companyName** (string, required): Name of the company/employer
* **candidatePosition** (string, required): The role held there. Keeps its original name, which is the name the API accepts.
* **referenceName** (string, optional): Name of the reference to call
* **referencePhone** (string, required): That reference's phone number in E.164 format (e.g. `+1234567890`)
* **startDate** (string, optional): Job start date in calendar format (`yyyy-MM-dd`)
* **endDate** (string, optional): Job end date in calendar format (`yyyy-MM-dd`)
## Error Scenarios
Errors answer with the flat body described in [Error Handling](/guides/error-handling): a
`statusCode`, a `message`, a `traceId`, and — on a rejected request body — an `errors` array
with one entry per rejected constraint.
```json theme={null}
{
"statusCode": 404,
"message": "Job not found",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
A phone number is unique per company, across every job. An email address is not — two
contacts may share one.
```json theme={null}
{
"statusCode": 409,
"message": "Candidate with this phone number already exists for this company.",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
```json theme={null}
{
"statusCode": 400,
"message": "Validation failed",
"errors": [
"Phone number must be in valid E.164 format starting with + (e.g. +1234567890)"
],
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4"
}
```
## Documents
A contact can hold documents an agent may look up during a call — a cover letter, application notes, and the contact's own processed CV. They are managed under `/contacts/{id}/documents` with the same three-step presigned upload as an [agent's knowledge base](/api-reference/agents/knowledge/start-upload).
`POST /contacts/{id}/documents/uploads` with `fileName`, `mimeType` and the file's exact `sizeBytes`. Both the slot and the bytes are reserved against your company's limits from this moment.
`PUT` the bytes to the returned `uploadUrl` with the returned `contentType`. No API key on this request.
`POST /contacts/{id}/documents/{documentId}/complete`. The file is verified and the document becomes `READY`.
Two things distinguish contact documents from agent documents:
* **The CV appears on its own.** Once a CV has been processed, the listing shows a document with `source: "CV"`. It is produced from the CV's anonymized text and cannot be renamed or deleted through the API — deactivate it (`PATCH` with `isActive: false`) to keep it out of calls.
* **Two limits.** At most **5** uploaded documents per contact, and a plan-tiered ceiling on your company's total contact-document storage. Both are reserved when an upload is started, and the `400` you get when the company ceiling is the problem names the bytes in use, the ceiling and the size requested.
**Listing a document does not put it on a call.** The agent must have
`contextConfig.useContactContext` enabled. With it off, calls run exactly as they did before,
documents or not.
Contact documents follow the contact's retention: when the contact reaches its GDPR expiry date, its documents are deleted with it, stored files and voice-provider copies included.
See the [Contact Documents reference](/api-reference/contacts/documents/list-documents) for the full endpoint set.
## Best Practices
Validate email and phone formats before submission
Implement duplicate detection logic
Keep each contact's status current as your process moves
Use signed URLs with expiration for resume access
## Next Steps
Start a conversation with a contact
Learn about job management
The full Contacts API reference
Production integration patterns
# Conversations
Source: https://docs.instaview.sk/guides/resources/conversations
Schedule and manage AI-run calls with the Conversations API
This resource is also `/conversations`. `/interviews` is a permanent alias that keeps working unchanged; the newer name is the one to reach for in new code. See [Resource names](/api-reference/introduction#resource-names).
## Overview
A conversation is one AI-run call to one contact, by phone or on the web. Each is run by an agent and leaves behind a transcript, a recording, and either an analysis or the analytics the agent was configured to extract.
## Resource Structure
```json theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"contactId": "contact-uuid",
"candidateId": "contact-uuid",
"agentId": "agent-uuid",
"runId": null,
"flowVersion": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"status": "COMPLETED",
"scheduledAt": "2024-01-20T14:00:00Z",
"durationMinutes": 23,
"finishedDate": "2024-01-20T14:25:30Z",
"metadata": {
"externalId": "CONV-789"
},
"callAttempts": [
{
"id": "attempt-uuid",
"direction": "OUTBOUND",
"status": "COMPLETED",
"scheduledAt": "2024-01-20T14:00:00Z",
"calledAt": "2024-01-20T14:00:15Z",
"duration": 1395,
"recordingUrl": "https://storage.example.com/recordings/conversation.mp3",
"flowVersion": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}
],
"analysis": {
"id": "analysis-uuid",
"general": {
"overallRating": 85,
"companyFitRating": "HIGH",
"education": "MASTER_LEVEL",
"experience": "THREE_TO_5_YEARS",
"strongPoints": ["Strong technical skills", "Clear communication"],
"weakPoints": ["Limited leadership experience"],
"evaluation": ["Strong hire for senior individual contributor role"],
"status": 3,
"createdAt": "2024-01-20T14:30:00Z",
"updatedAt": "2024-01-20T14:30:00Z"
},
"specific": null
},
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-20T14:25:30Z"
}
```
`runId` is `null` on a conversation created on its own, and carries the run's id on one a
[run](/guides/resources/runs) produced — a run mints one conversation per contact. The
`conversation.*` and `analysis.*` [webhook payloads](/guides/webhooks#payload-structure) carry
it too, so a batch can be attributed without reading the conversation back.
`flowVersion` identifies the agent configuration a call was placed with: its flow, guardrails
and context, as compiled at dial time. It is a content hash, so two calls that ran the same
configuration carry the same value and any edit that changes what the agent says produces a
new one. That is what answers "which questions was this contact actually asked?" after the
agent has since been edited: compare the call's `flowVersion` with another call's, or with
the value your own records hold for an approved version. Each call attempt carries its own,
because a retry placed after an edit runs the edited configuration; the conversation's value
is the most recent attempt's. It is `null` for agents without a designed flow, for inbound
calls, and for calls placed before September 23, 2026. An online (browser) conversation
records it on the conversation only, so its attempts read `null` while the conversation
carries the value. We keep the configuration behind every
value for as long as a conversation that used it exists, and the value never exposes the
prompt itself.
Each call attempt's `direction` says how the call was made: `OUTBOUND` when we dialled the
contact, `INBOUND` when the contact called in, and `ONLINE` for a browser session on an
online conversation. `UNDEFINED` appears only on old records. Treat the list as open: a new
channel can add a value.
The `analysis` above is the recruiting pipeline's output. A conversation run by a [custom
agent](/guides/resources/agents#custom-agents) instead carries an
[`analytics`](#custom-agent-analytics) object beside it — usually **without** an `analysis`,
since none of the hiring jobs apply to it.
## Required Scopes
| Operation | Required Scope | Additional |
| - | - | - |
| List conversations | `read:conversations` | `read:contacts`, `read:agents` |
| Get conversation by ID | `read:conversations` | |
| Create conversation | `write:conversations` | `read:contacts`, `read:agents` |
| Delete conversation | `delete:conversations` | |
The legacy aliases (`read:interviews`, `write:interviews`, `delete:interviews`) work identically and stay valid on keys that already carry them — see
[Scopes and Permissions](/guides/scopes-and-permissions).
## Creating Conversations
### With an Existing Contact and Agent
```javascript theme={null}
const conversation = await fetch("https://api.instaview.sk/conversations", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INSTAVIEW_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
contactId: "contact-uuid",
jobId: "job-uuid", // Optional: which job (required when the contact holds several). Or use `job` to define one inline.
agentId: "agent-uuid",
scheduleTime: "2024-01-20T14:00:00Z", // Optional: ISO 8601, max 30 days out. Omit to start as soon as possible.
}),
});
```
**Job selection**: with an existing `contactId` you may name a `jobId` to record which job
the conversation is for. It is **required** when the contact is associated with several jobs.
The job must belong to your API key's company, but it need not be one the contact already
holds: if they are not assigned to it, creating the conversation assigns them.
### With an Inline Contact
Create a conversation with a contact that does not exist yet. A job is optional:
**With Job Association:**
```javascript theme={null}
const conversation = await createConversation({
contact: {
jobId: "job-uuid",
firstName: "Jane",
lastName: "Doe",
email: "jane@example.com",
phoneNumber: "+1234567890",
gdprExpiryDate: "2026-11-16",
},
agentId: "agent-uuid",
scheduleTime: "2024-01-20T14:00:00Z",
});
```
**Without Job Association:**
```javascript theme={null}
const conversation = await createConversation({
contact: {
firstName: "John",
lastName: "Smith",
email: "john@example.com",
phoneNumber: "+1234567890",
gdprExpiryDate: "2026-11-16",
// jobId is optional - the contact is created without a job assignment
},
agentId: "agent-uuid",
scheduleTime: "2024-01-20T14:00:00Z",
});
```
**Inline contact fields**: `gdprExpiryDate` is required, and must be a valid ISO 8601 date —
it is when this person's data stops being yours to keep. `jobId` is optional; omit it to
create the contact with no job assignment and attach jobs later through the [update contact
endpoint](/api-reference/contacts/update-contact).
### With an Inline Job (Existing Contact)
Create a conversation for a contact you already hold while defining the job inline — useful when you know the job but do not want to create it as a separate request first.
```javascript theme={null}
const conversation = await createConversation({
contactId: "contact-uuid",
job: {
jobTitle: "Senior Backend Engineer",
jobDescription: "We are looking for a senior engineer...",
requiredSkills: ["TypeScript", "NestJS", "PostgreSQL"],
niceToHaveSkills: ["Redis", "AWS"],
languages: ["EN"],
experience: "SENIOR",
contractType: "FULL_TIME",
},
agentId: "agent-uuid",
scheduleTime: "2024-01-20T14:00:00Z",
});
```
**Inline Job Fields**: - `jobTitle` is required and must be between 5–200
characters. - Arrays like `requiredSkills` and `niceToHaveSkills` accept up to
10 skills each; each skill is a non-empty string up to 128 characters.
Combined total cannot exceed 10 skills. - Enum fields (such as `languages`,
`education`, `experience`, `contractType`) use the same values as the Jobs
API. - Nested objects `location` and `salary` reuse the core Job DTOs, so data
stored is identical to jobs created via the Jobs API. The inline job is
persisted as a regular **Job** during creation, and the conversation is
linked to it.
**XOR with `jobId`**: - Use `jobId` when you already have a job created. - Use
`job` when you want to define the job inline. - You must not send both `jobId`
and `job` in the same request (XOR behavior).
### With an Inline Contact and an Inline Job
Define both inline in a single request, when you already hold everything you need:
```javascript theme={null}
const conversation = await createConversation({
contact: {
firstName: "Jane",
lastName: "Doe",
email: "jane@example.com",
phoneNumber: "+1234567890",
gdprExpiryDate: "2026-11-16",
},
job: {
jobTitle: "Senior Backend Engineer",
jobDescription: "We are looking for a senior engineer...",
requiredSkills: ["TypeScript", "NestJS", "PostgreSQL"],
niceToHaveSkills: ["Redis", "AWS"],
languages: ["EN"],
experience: "SENIOR",
contractType: "FULL_TIME",
},
agentId: "agent-uuid",
scheduleTime: "2024-01-20T14:00:00Z",
});
```
**Complete inline workflow**: combining `contact` with `job` - creates a new **Job** for your
company - creates a new **Contact**, automatically linked to that job - associates the
**Conversation** with both - and leaves all three as permanent resources. Nothing has to be
created in an earlier request.
**Ways to name the job**: 1. **Inline**: `job` at the top level defines and creates it in one
request 2. **By reference**: `jobId` at the top level, for a job that exists 3. **Compact**:
`jobId` inside `contact`, when creating the contact in the same request. **Priority**: if
several are sent, `job` > top-level `jobId` > `contact.jobId`. **Do not** combine
`contact.jobId` with a top-level `jobId` or `job` — which job you meant would be a guess.
### Scheduling
**Scheduling Constraint**: The `scheduleTime` must be a valid ISO 8601
date-time string and cannot exceed **30 days in the future**. Attempts to
schedule beyond this limit will result in a `400 Bad Request` error.
`scheduleTime` is optional. Omit it to place the call as soon as the agent's calling hours allow, or send a future timestamp to pin it.
```javascript theme={null}
// As soon as possible (omit scheduleTime)
{
contactId: 'contact-uuid',
agentId: 'agent-uuid'
}
// Scheduled for later
{
contactId: 'contact-uuid',
agentId: 'agent-uuid',
scheduleTime: '2024-01-20T14:00:00Z' // Optional: ISO 8601 format, max 30 days in future
}
```
## Deleting Conversations
Deleting a conversation removes it permanently, along with its transcript, recording and analysis.
```javascript theme={null}
async function deleteConversation(conversationId) {
const response = await fetch(
`https://api.instaview.sk/conversations/${conversationId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.INSTAVIEW_API_KEY}`,
},
}
);
const data = await response.json();
return data; // { id, deleted: true }
}
```
The conversation row itself is deleted outright, and its transcripts, recordings and analyses
go with it — a deleted conversation is gone from list and get, and no endpoint returns it
again. See [Delete Conversation](/api-reference/conversations/delete-conversation) for what
happens to a call that is in progress at the time.
## Conversation Status
`status` moves through the non-terminal values and settles on one of four terminal values. A terminal status holds until the conversation is deliberately reopened — see [Reopening a terminal conversation](#reopening-a-terminal-conversation) below.
| Status | Terminal | Meaning |
| - | - | - |
| `SCHEDULED` | No | An attempt is queued and waiting. `scheduledAt` holds its time. A retried conversation reads this between attempts, so it does not imply no call has been made yet. |
| `IN_PROGRESS` | No | A call is live right now. It returns to `SCHEDULED` once the attempt ends and the next one is queued. |
| `COMPLETED` | Yes | The call happened. Transcript, recording and results follow. |
| `UNREACHABLE` | Yes | Every permitted attempt was made and the contact never picked up — no answer, busy, or voicemail. |
| `FAILED` | Yes | A technical failure prevented the call being placed or held. |
| `CANCELLED` | Yes | Called off deliberately, from the InstaView dashboard or by InstaView staff. |
| `UNDEFINED` | No | Not yet initialised. Transient; you should not observe it in practice. |
**`UNREACHABLE` is new as of August 2026.** It splits the "we could not reach them"
cohort out of `FAILED` and `CANCELLED`, which previously carried it
inconsistently depending on where the retry budget ran out. If you filter or report on
either of those, add `UNREACHABLE` — see the [August 2026 changelog](/changelog/2026-08).
### Reopening a terminal conversation
Rescheduling a conversation that has already settled returns it to `SCHEDULED` and clears `finishedDate`. This applies to every terminal status — `FAILED`, `UNREACHABLE`, `CANCELLED` and `COMPLETED` alike. It keeps the same id and runs to an outcome as normal, so a terminal status is better read as *terminal until somebody asks for another call* than as final.
Only a deliberate action reopens one — a reschedule, an automatic retry, or an explicitly invoked attempt. Nothing reopens on its own. A reopened `COMPLETED` conversation keeps every transcript, recording and analysis it already has: those belong to the attempts that produced them, not to the conversation's current status, so calling again adds to the record rather than replacing it.
**If you stop polling when a conversation goes terminal**, subscribe to
[`conversation.rescheduled`](/guides/webhooks#conversation-rescheduled-payload) and resume
when it fires. It is emitted on every reopen, so a poller that ignores it will never see
the outcome of the new call. Until 28 August 2026 a terminal status was documented as
never changing again — see the [August 2026 changelog](/changelog/2026-08).
## Retrieving Results
### Get a Conversation with its Analysis
```javascript theme={null}
async function getConversationResults(conversationId) {
const response = await fetch(
`https://api.instaview.sk/conversations/${conversationId}`,
{ headers: { Authorization: `Bearer ${apiKey}` } }
);
const data = await response.json();
return {
status: data.status,
durationMinutes: data.durationMinutes,
finishedDate: data.finishedDate,
overallRating: data.analysis?.general?.overallRating,
strongPoints: data.analysis?.general?.strongPoints,
weakPoints: data.analysis?.general?.weakPoints,
evaluation: data.analysis?.general?.evaluation,
specific: data.analysis?.specific,
};
}
```
### List a Contact's Conversations
```javascript theme={null}
async function getContactConversations(contactId) {
const response = await fetch(
`https://api.instaview.sk/conversations?contactId=${contactId}`,
{ headers: { Authorization: `Bearer ${apiKey}` } }
);
return await response.json();
}
```
## Custom Agent Analytics
A conversation run by a [custom agent](/guides/resources/agents#custom-agents) carries a second, separate object: **`analytics`**, holding whatever that agent's `analyticsConfig` asked for.
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 — there is no `analysisPdfBase64` either, since the PDF report is a recruiting artefact. `analytics` is the agent's own design, and it is the only one of the two that appears outside hiring.
```json theme={null}
{
"analytics": {
"matchScore": 72,
"fields": [
{ "key": "budget_confirmed", "label": "Budget confirmed", "type": "bool",
"value": true, "confidence": 0.9, "evidence": "Signed off on 15k this quarter" },
{ "key": "timeline", "label": "Timeline", "type": "string", "value": null }
],
"scoring": {
"total": 72,
"items": [
{ "key": "budget_confirmed", "kind": "field", "label": "Budget confirmed",
"score": 90, "weightPercent": 45.4, "importance": "REQUIRED" }
]
},
"outcomes": [{ "key": "meeting_booked", "label": "Meeting booked", "met": true }],
"tags": [{ "key": "needs_follow_up", "label": "Needs follow-up", "applied": false }],
"qa": [
{ "id": "3f1a…-question-uuid", "question": "What are you using today?",
"answered": true, "answer": "Spreadsheets, rebuilt by hand every week",
"evidence": "It's the worst two hours of my Monday" }
],
"evaluation": {
"strengths": ["Clear pain point, already shopping for a replacement"],
"concerns": ["Finance sign-off not secured"],
"objections": ["Worried about migrating two years of historical data"],
"assessment": ["Strong fit; the only real risk is procurement timing"]
},
"sentiment": { "overall": "positive", "score": 0.8, "trajectory": "improving" },
"summary": "Qualified lead; demo booked for Thursday.",
"updatedAt": "2026-08-04T14:30:00Z"
}
}
```
Reading it:
* The `key` on each field, outcome and tag is the one configured on the agent, so you can switch on it directly.
* **`value: null` means the call did not surface it** — 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 entirely when the value is `null`.
* **`weightPercent` is each item's share of the match score**, and the shares sum to exactly 100. The weights you configured are relative, not percentages, so a raw `50` tells a consumer nothing on its own.
* In `scoring.items`, an entry with `kind: "question"` carries the flow question's **id** rather than a configured key — a question has none, which is why its scoring is set inline on the question. `kind: "field"` and `"criterion"` carry the configured keys.
* `matchScore` is always present, and `null` when scoring has not run yet.
* In `qa`, **`answered: false` means the call never reached the question** — not that it was answered with nothing. `answer` is an empty string in both cases, so switch on `answered`, not on the string.
* `evaluation` is four independent arrays of lines (`strengths`, `concerns`, `objections`, `assessment`). Any one of them can be empty while the others are populated, so check each rather than the object.
* Each slice comes from its own after-call job, and each runs only when the agent asked for it — so an absent `outcomes` or `sentiment` means "not configured, or not run yet".
The same object is pushed to your `analysis.completed`
[webhook](/guides/webhooks#analysis-events-payload), so you do not have to poll for it. It
also appears on every row of `GET /conversations`.
## Analysis Structure
This section describes `analysis` — the **recruiting** pipeline's output, produced for
agents with a hiring `focus`. For a custom agent, see [Custom Agent
Analytics](#custom-agent-analytics) above.
The analysis object is the hiring assessment. It contains a `general` object holding the overall verdict and an optional `specific` object carrying whatever the agent's focus produces.
### General Analysis Fields
| Field | Type | Description |
| - | - | - |
| `overallRating` | number \| null | Overall rating (0-100). `null` when scoring did not complete |
| `scoringIncompleteReason` | string | Why no rating was produced: `model_unavailable`, `model_error` |
| `companyFitRating` | string | Company fit: `UNDEFINED`, `LOW`, `MEDIUM`, `HIGH` |
| `education` | string | Education level (see enum values below) |
| `experience` | string | Experience level (see enum values below) |
| `strongPoints` | string\[] | Strong points and advantages |
| `weakPoints` | string\[] | Areas for improvement |
| `evaluation` | string\[] | Overall evaluation summary and recommendations |
| `status` | number | Analysis status: `1`=PENDING, `2`=PROCESSING, `3`=COMPLETED, `4`=FAILED |
| `createdAt` | string | ISO 8601 timestamp |
| `updatedAt` | string | ISO 8601 timestamp |
#### When there is no rating
`overallRating` comes back `null` when the model that scores the conversation could not be reached.
It is not zero and not a low score: no rating was produced. `scoringIncompleteReason` says why, and it
is the field to branch on rather than treating a missing rating as a bad one. A conversation that was
never analysed also has a `null` rating, and no `scoringIncompleteReason`.
| Reason | What happened | What we do next |
| - | - | - |
| `model_unavailable` | The scoring model could not be reached and the retries were spent | The analysis is queued to run again, spaced over roughly seven minutes |
| `model_error` | It answered unusably, or the failure was permanent | Nothing automatic; the analysis stays as it is with the reason on the record |
The rest of the analysis is unaffected — `strongPoints`, `weakPoints`, `evaluation` and the
`specific` data are produced by different calls and are present as usual. On a `model_unavailable`
run that recovers, the rating appears and the reason disappears; the
[`analysis.completed` webhook](/guides/webhooks#analysis-events-payload) fires again with the
filled-in analysis.
```javascript theme={null}
// Sort a shortlist without treating "not scored" as "scored zero".
const rated = conversations.filter((c) => c.analysis?.general?.overallRating != null);
const unscored = conversations.filter((c) => c.analysis?.general?.scoringIncompleteReason);
```
**Education Level Values**: `UNDEFINED`, `PRIMARY_EDUCATION`, `SECONDARY_SCHOOL_STUDENT`, `SECONDARY_WITHOUT_DIPLOMA`, `SECONDARY_WITH_DIPLOMA`, `POST_SECONDARY_VOCATIONAL`, `UNIVERSITY_STUDENT`, `BACHELOR_LEVEL`, `MASTER_LEVEL`, `POSTGRADUATE`
**Experience Level Values**: `UNDEFINED`, `NO_EXPERIENCE`, `LESS_THAN_1_YEAR`, `ONE_TO_3_YEARS`, `THREE_TO_5_YEARS`, `FIVE_TO_10_YEARS`, `TEN_TO_15_YEARS`, `MORE_THAN_15_YEARS`
```javascript theme={null}
{
analysis: {
id: "analysis-uuid",
general: {
overallRating: 85,
companyFitRating: "HIGH",
education: "MASTER_LEVEL",
experience: "THREE_TO_5_YEARS",
strongPoints: [
"Strong technical skills in React and Node.js",
"Excellent problem-solving abilities"
],
weakPoints: [
"Limited experience with cloud infrastructure"
],
evaluation: [
"Strong hire for senior IC role",
"Consider for team lead in 6-12 months"
],
status: 3,
createdAt: "2024-01-20T14:30:00Z",
updatedAt: "2024-01-20T14:30:00Z"
},
specific: null // or focus-specific data
}
}
```
## Conversation Types & Analysis
What lands in `specific` depends on the agent's focus. There are four hiring focuses; a [custom agent](#custom-agent-analytics) has none of them and produces `analytics` instead.
**Standard hiring screen**
Evaluates the contact against the job's requirements. The analysis uses only the `general` fields — there is no `specific` data.
```json theme={null}
{
"analysis": {
"id": "analysis-uuid",
"general": {
"overallRating": 85,
"companyFitRating": "HIGH",
"education": "MASTER_LEVEL",
"experience": "THREE_TO_5_YEARS",
"strongPoints": ["Strong technical background", "Good communication"],
"weakPoints": ["Limited management experience"],
"evaluation": ["Recommended for next round"],
"status": 3,
"createdAt": "2024-01-20T14:30:00Z",
"updatedAt": "2024-01-20T14:30:00Z"
},
"specific": null
}
}
```
**Recruitment outreach calls**
A first call to gauge interest. `specific` carries:
| Field | Type | Description |
| - | - | - |
| `interestType` | string | How interested the contact sounded |
| `specificFeedback` | string | What they said, in detail |
| `preferredNextStep` | string | The next step they asked for |
| `rescheduleTime` | number | A callback time they named, if any |
```json theme={null}
{
"analysis": {
"id": "analysis-uuid",
"general": {
"overallRating": 75,
"companyFitRating": "MEDIUM",
"strongPoints": ["Interested in the role", "Available immediately"],
"weakPoints": ["Salary expectations may be high"],
"evaluation": ["Schedule follow-up interview"],
"status": 3,
"createdAt": "2024-01-20T14:30:00Z",
"updatedAt": "2024-01-20T14:30:00Z"
},
"specific": {
"interestType": "INTERESTED",
"specificFeedback": "Candidate expressed strong interest in the backend role",
"preferredNextStep": "Technical interview next week"
}
}
}
```
**Job-optional conversations**
For calls that are not about a particular job, so no job assignment is needed. Same analysis shape as OUTREACH:
| Field | Type | Description |
| - | - | - |
| `interestType` | string | How interested the contact sounded |
| `specificFeedback` | string | What they said, in detail |
| `preferredNextStep` | string | The next step they asked for |
| `rescheduleTime` | number | A callback time they named, if any |
```json theme={null}
{
"analysis": {
"id": "analysis-uuid",
"general": {
"overallRating": 70,
"companyFitRating": "MEDIUM",
"strongPoints": ["Open to opportunities"],
"weakPoints": ["Not actively looking"],
"evaluation": ["Add to talent pool for future positions"],
"status": 3,
"createdAt": "2024-01-20T14:30:00Z",
"updatedAt": "2024-01-20T14:30:00Z"
},
"specific": {
"interestType": "CALLBACK_REQUESTED",
"specificFeedback": "Prefers to be contacted in Q2",
"preferredNextStep": "Follow up in 3 months"
}
}
}
```
**Language proficiency assessment**
Rates the contact's language skills against CEFR. `specific` carries the detail:
| Field | Type | Description |
| - | - | - |
| `pronunciationScores` | object | Pronunciation metrics (accuracy, fluency, prosody: 50-95) |
| `pronunciationAssessment` | object | CEFR pronunciation level (A1-C2) with reasoning |
| `grammar` | object | Grammar assessment with CEFR level and summary |
| `vocabulary` | object | Vocabulary assessment with CEFR level and summary |
| `overallScore` | object | Combined CEFR level with confidence score |
| `combinedCefrEvaluation` | object | Final CEFR evaluation with confidence and summary |
```json theme={null}
{
"analysis": {
"id": "analysis-uuid",
"general": {
"overallRating": 82,
"companyFitRating": "HIGH",
"strongPoints": ["Fluent speaker", "Good vocabulary range"],
"weakPoints": ["Minor grammatical errors"],
"evaluation": ["Meets B2 requirement for the position"],
"status": 3,
"createdAt": "2024-01-20T14:30:00Z",
"updatedAt": "2024-01-20T14:30:00Z"
},
"specific": {
"pronunciationScores": {
"accuracy": 85,
"fluency": 88,
"prosody": 82
},
"pronunciationAssessment": {
"estimated_pronunciation_level": "B2",
"reasoning": "Clear articulation with occasional non-native patterns"
},
"grammar": {
"level": "B2",
"meets_tested_level": true,
"summary": "Consistent use of complex structures with minor errors"
},
"vocabulary": {
"level": "C1",
"meets_tested_level": true,
"summary": "Rich vocabulary with appropriate professional terminology"
},
"overallScore": {
"level": "B2",
"confidence": 0.85,
"summary": "Strong B2 level with elements of C1 vocabulary"
},
"combinedCefrEvaluation": {
"final_level": "B2",
"confidence": 0.87,
"summary": "Candidate demonstrates solid B2 proficiency"
}
}
}
}
```
## Best Practices
Verify billing limits before bulk scheduling
Leave buffer time, and let the calling window pick the hour
Poll status, or take the webhook instead
Save analysis data to your database
## Next Steps
Create and configure the agents that run the calls
Learn about managing contacts
What deletion removes, and what it cannot undo
Understand call costs and limits
# Jobs
Source: https://docs.instaview.sk/guides/resources/jobs
Manage job postings and position requirements with the Jobs API
## Overview
The Jobs API creates and organizes job postings within your company. A job groups contacts and the conversations held about it — the backbone of a hiring workflow, and optional everywhere else.
## Resource Structure
```json theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"companyId": "987e6543-e21b-12d3-a456-426614174000",
"title": "Senior Software Engineer",
"description": "We are seeking an experienced software engineer...",
"requiredSkills": ["JavaScript", "React", "Node.js", "TypeScript"],
"niceToHaveSkills": ["GraphQL", "Docker", "AWS"],
"languageRequirements": [
{ "language": "EN", "proficiency": "C1" },
{ "language": "ES", "proficiency": "B1" }
],
"location": {
"workMode": "HYBRID",
"street": "123 Tech Street",
"city": "San Francisco",
"postalCode": "94105",
"countryCode": "US"
},
"salary": {
"min": 120000,
"max": 180000,
"currency": "USD",
"period": "YEARLY"
},
"jobUrl": "https://company.com/careers/senior-engineer",
"status": "OPEN",
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
```
## Required Scopes
| Operation | Required Scope |
| - | - |
| List jobs | `read:jobs` |
| Get job by ID | `read:jobs` |
| Create job | `write:jobs` |
| Create job from URL | `write:jobs` |
| Update job | `write:jobs` |
| Delete job | `delete:jobs` |
## Creating Jobs
### Basic Job Creation
```bash cURL theme={null}
curl -X POST https://api.instaview.sk/jobs \
-H "Authorization: Bearer sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"title": "Senior Software Engineer",
"description": "We are seeking an experienced software engineer...",
"requiredSkills": ["JavaScript", "React", "Node.js"],
"status": "OPEN"
}'
```
```javascript Node.js theme={null}
const job = await fetch("https://api.instaview.sk/jobs", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INSTAVIEW_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "Senior Software Engineer",
description: "We are seeking an experienced software engineer...",
requiredSkills: ["JavaScript", "React", "Node.js"],
status: "OPEN",
}),
});
const result = await job.json();
console.log("Job created:", result.id);
```
```python Python theme={null}
response = requests.post(
'https://api.instaview.sk/jobs',
headers={
'Authorization': f'Bearer {os.environ["INSTAVIEW_API_KEY"]}',
'Content-Type': 'application/json'
},
json={
'title': 'Senior Software Engineer',
'description': 'We are seeking an experienced software engineer...',
'requiredSkills': ['JavaScript', 'React', 'Node.js'],
'status': 'OPEN'
}
)
job = response.json()
print(f'Job created: {job["id"]}')
```
### Create Job from URL
You can also create a job by providing a URL to an existing job posting. The API will scrape the page, extract structured data using AI, and create the job automatically. Set `createJob` to `false` to only extract data without creating a job entity:
```bash cURL theme={null}
curl -X POST https://api.instaview.sk/jobs/url \
-H "Authorization: Bearer your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"jobUrl": "https://example.com/careers/senior-developer"
}'
```
```javascript Node.js theme={null}
const response = await fetch("https://api.instaview.sk/jobs/url", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INSTAVIEW_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
jobUrl: "https://example.com/careers/senior-developer",
}),
});
const result = await response.json();
console.log("Job created:", result.id);
```
```python Python theme={null}
response = requests.post(
'https://api.instaview.sk/jobs/url',
headers={
'Authorization': f'Bearer {os.environ["INSTAVIEW_API_KEY"]}',
'Content-Type': 'application/json'
},
json={
'jobUrl': 'https://example.com/careers/senior-developer'
}
)
result = response.json()
print(f'Job created: {result["data"]["id"]}')
```
Set `createJob` to `false` to preview extracted data without creating a job.
The response returns **200** (instead of **201**) and `data` contains a preview with all extracted fields — `id` and timestamps will be empty strings.
This endpoint has a stricter rate limit of **5 requests per minute** due to
web scraping and AI processing.
### Comprehensive Job Creation
```javascript theme={null}
const comprehensiveJob = {
// Required fields
title: "Senior Software Engineer",
description:
"We are seeking an experienced software engineer with 5+ years of experience...",
// Skills
requiredSkills: ["JavaScript", "React", "Node.js", "TypeScript", "REST APIs"],
niceToHaveSkills: ["GraphQL", "Docker", "Kubernetes", "AWS"],
// Language requirements (ISO 639-1 language codes + CEFR proficiency levels)
languageRequirements: [
{ language: "EN", proficiency: "C1" }, // English - Proficient
{ language: "ES", proficiency: "B1" }, // Spanish - Intermediate
],
// Location
location: {
workMode: "HYBRID", // Options: REMOTE, HYBRID, ONSITE
street: "123 Tech Street",
city: "San Francisco",
postalCode: "94105",
countryCode: "US",
},
// Compensation
salary: {
min: 120000,
max: 180000,
currency: "USD",
period: "YEARLY",
},
// Additional info
jobUrl: "https://company.com/careers/senior-engineer",
status: "OPEN",
};
const response = await createJob(comprehensiveJob);
```
## Listing Jobs
### List All Jobs
```javascript theme={null}
async function listJobs(page = 1, limit = 20) {
const response = await fetch(
`https://api.instaview.sk/jobs?page=${page}&limit=${limit}`,
{
headers: { Authorization: `Bearer ${apiKey}` },
},
);
const result = await response.json();
return result.data;
}
// Usage
const { items, pagination } = await listJobs(1, 50);
console.log(`Found ${pagination.total} jobs`);
```
### Filter by Status
```javascript theme={null}
async function getActiveJobs() {
const response = await fetch(
"https://api.instaview.sk/jobs?status=OPEN&limit=100",
{
headers: { Authorization: `Bearer ${apiKey}` },
},
);
return await response.json();
}
```
### Search Jobs
```javascript theme={null}
async function searchJobs(query) {
const response = await fetch(
`https://api.instaview.sk/jobs?search=${encodeURIComponent(query)}`,
{
headers: { Authorization: `Bearer ${apiKey}` },
},
);
return await response.json();
}
// Search by title
const engineerJobs = await searchJobs("engineer");
```
**Best Practice**: For production integrations, always implement request timeouts.
```javascript theme={null}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
try {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
```
## Updating Jobs
### Partial Update
```javascript theme={null}
async function updateJob(jobId, updates) {
const response = await fetch(`https://api.instaview.sk/jobs/${jobId}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(updates),
});
return await response.json();
}
// Update only specific fields
await updateJob("job-uuid", {
title: "Staff Software Engineer", // Promoted!
salary: {
min: 150000,
max: 220000,
currency: "USD",
period: "YEARLY",
},
});
```
**Best Practice**: For production integrations, always implement request timeouts.
```javascript theme={null}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
try {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
```
### Change Job Status
```javascript theme={null}
// Mark job as closed
await updateJob("job-uuid", { status: "CLOSED" });
// Reopen job
await updateJob("job-uuid", { status: "OPEN" });
```
## Job Status Management
### Available Statuses
Job is open for applications - Can schedule conversations against it - Accepts new
applications
Position has been filled or paused - Existing contacts retained -
No new applications accepted
### Status Workflow
```javascript theme={null}
class JobWorkflow {
async createJob(jobData) {
return await createJob({
...jobData,
status: "OPEN", // Jobs start as OPEN
});
}
async closeJob(jobId) {
return await updateJob(jobId, { status: "CLOSED" });
}
async reopenJob(jobId) {
return await updateJob(jobId, { status: "OPEN" });
}
}
```
## Location and Work Mode
### Work Modes
| Mode | Description |
| - | - |
| `REMOTE` | Fully remote position |
| `ONSITE` | Office-based only |
| `HYBRID` | Mix of remote and office |
### Location Structure
```javascript theme={null}
{
location: {
street: '123 Tech Street', // Optional
city: 'San Francisco', // Recommended
postalCode: '94105', // Optional
countryCode: 'US' // Required (ISO 3166-1 alpha-2)
}
}
```
### Examples
```javascript theme={null}
// Fully remote position
{
location: {
workMode: 'REMOTE',
countryCode: 'US' // Country for legal/tax purposes
}
}
// Hybrid with specific office
{
location: {
workMode: 'HYBRID',
street: '1 Apple Park Way',
city: 'Cupertino',
postalCode: '95014',
countryCode: 'US'
}
}
// On-site position
{
location: {
workMode: 'ONSITE',
city: 'New York',
countryCode: 'US'
}
}
```
## Salary Information
### Salary Structure
```javascript theme={null}
{
salary: {
min: 100000, // Minimum compensation
max: 150000, // Maximum compensation
currency: 'USD', // ISO 4217 currency code
period: 'YEARLY' // Options: 'YEARLY', 'MONTHLY', 'HOURLY'
}
}
```
### Examples
```javascript theme={null}
// Annual salary range
{
salary: {
min: 120000,
max: 180000,
currency: 'USD',
period: 'YEARLY'
}
}
// Hourly rate
{
salary: {
min: 75,
max: 125,
currency: 'USD',
period: 'HOURLY'
}
}
// Monthly salary (common in some regions)
{
salary: {
min: 8000,
max: 12000,
currency: 'EUR',
period: 'MONTHLY'
}
}
// Omit salary (not disclosed)
{
// salary field can be omitted entirely
}
```
## Skills Management
### Best Practices
```javascript theme={null}
// ✅ Good: Specific technologies
requiredSkills: ['React 18+', 'Node.js', 'PostgreSQL', 'TypeScript']
// ❌ Too vague
requiredSkills: ['Frontend', 'Backend', 'Database']
```
```javascript theme={null}
{
requiredSkills: [
'JavaScript',
'React',
'REST APIs'
],
niceToHaveSkills: [
'TypeScript',
'GraphQL',
'Docker'
]
}
```
```javascript theme={null}
// ✅ Consistent
requiredSkills: ['JavaScript', 'TypeScript', 'Node.js']
// ❌ Inconsistent casing/spelling
requiredSkills: ['javascript', 'Typescript', 'NodeJS']
```
## Deleting Jobs
### Soft Delete
Jobs are soft-deleted by default:
```javascript theme={null}
async function deleteJob(jobId) {
const response = await fetch(`https://api.instaview.sk/jobs/${jobId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${apiKey}` },
});
return await response.json();
}
```
**This is a hard delete, and it cascades.** The job goes, and so does every contact whose job
it is — and with them their conversations, transcripts, recordings and analyses. A contact
assigned to this job *and* others goes too: the cascade follows the job, not "contacts left
with no job". None of it is readable through the API afterwards.
To stop using a job without losing anything, set its `status` to `CLOSED` instead. See
[Delete Job](/api-reference/jobs/delete-job).
### Before Deleting
Consider the impact:
```javascript theme={null}
async function safeDeleteJob(jobId) {
// Check for contacts attached to the job
const contacts = await listContacts({ jobId });
if (contacts.total > 0) {
console.warn(`Job has ${contacts.total} contacts — deleting it deletes them`);
const confirm = await askUser("Delete anyway?");
if (!confirm) return;
}
// Proceed with deletion
return await deleteJob(jobId);
}
```
## Common Patterns
### Bulk Job Creation
```javascript theme={null}
async function bulkCreateJobs(jobsData) {
const results = [];
const errors = [];
for (const jobData of jobsData) {
try {
const job = await createJob(jobData);
results.push(job);
// Rate limiting: wait between requests
await sleep(200);
} catch (error) {
errors.push({
jobData,
error: error.message,
});
}
}
return { results, errors };
}
```
### Job Synchronization
```javascript theme={null}
async function syncJobsFromATS(atsJobs) {
const existingJobs = await listAllJobs();
const existingJobsMap = new Map(existingJobs.map((j) => [j.externalId, j]));
for (const atsJob of atsJobs) {
const existing = existingJobsMap.get(atsJob.id);
if (!existing) {
// Create new job
await createJob(transformAtsJob(atsJob));
} else if (hasChanges(existing, atsJob)) {
// Update existing job
await updateJob(existing.id, transformAtsJob(atsJob));
}
}
}
```
### Job Analytics
```javascript theme={null}
async function getJobStats(jobId) {
const [contacts, conversations] = await Promise.all([
listContacts({ jobId }),
listConversations({ jobId }),
]);
return {
totalContacts: contacts.total,
activeContacts: contacts.data.filter((c) => c.status === "IN_PROCESS")
.length,
totalConversations: conversations.total,
completedConversations: conversations.data.filter(
(i) => i.status === "completed",
).length,
};
}
```
## Validation Rules
Job title (1-200 characters)
Detailed job description (max 10,000 characters)
Array of required skills (max 50 items, each max 100 chars)
One of: `REMOTE`, `ONSITE`, `HYBRID`
ISO 3166-1 alpha-2 country code (e.g., "US", "GB", "DE")
ISO 4217 currency code (e.g., "USD", "EUR", "GBP")
One of: `OPEN`, `CLOSED`, `UNDEFINED` **Note**: In practice, use `OPEN`
(accepting applications) or `CLOSED` (no longer hiring)
## Error Scenarios
Errors answer with the flat body described in [Error Handling](/guides/error-handling): a
`statusCode`, a `message`, a `traceId`, and — on a rejected request body — an `errors` array
with one entry per rejected constraint.
```json theme={null}
{
"statusCode": 400,
"message": "Validation failed",
"errors": ["Invalid work mode"],
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4"
}
```
`workMode` is one of `ONSITE`, `REMOTE`, `HYBRID` or `UNDEFINED`.
```json theme={null}
{
"statusCode": 404,
"message": "Job not found",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
Every by-id route (`GET`, `PATCH`, `DELETE`) answers this for an id that
exists nowhere **and** for an id that belongs to another company — same
status, same body. The two are deliberately indistinguishable, so a `404`
never confirms that an id exists somewhere else. Do not branch on it to
detect a permission problem; treat it as "this job is not available to
this key".
## Best Practices
Create jobs as drafts while preparing content
Maintain a standardized skill taxonomy
Transparency improves who applies
Keep job descriptions current and accurate
## Next Steps
Add contacts to your jobs
Call the contacts on a job
View complete Jobs API reference
Production integration patterns
# Runs
Source: https://docs.instaview.sk/guides/resources/runs
Monitor a batch of AI-run calls with the Runs API
## Overview
A **run** is one agent dialling a list of contacts. It is the batch primitive: instead of creating a conversation per person and tracking hundreds of ids, you launch a run and read one aggregate.
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 a conversation you created on its own. So everything the [Conversations](/guides/resources/conversations) guide says about transcripts, analysis, analytics and webhooks holds for a conversation a run produced. What the run adds is a lifecycle and a roll-up.
## Building and running one
Three requests, and none of them can call anybody twice:
```javascript theme={null}
POST /runs // an empty draft
POST /runs/{id}/contacts // add contacts — attaching an id twice is a no-op
POST /runs/{id}/launch // one conversation per contact, now or up to 30 days out
```
Then `pause`, `resume` and `cancel` drive it, and `GET /runs/{id}` reports where it has got to.
**Create takes no contacts, on purpose.** If it did, a request that timed out after the server
committed would leave you retrying into a *second* draft holding the same list — dialling
nothing, but sitting there launchable. A retried create leaves an empty draft instead, which
holds nothing anyone can launch by mistake. It is also why this API has no `Idempotency-Key`
header: the one endpoint that would have needed one does not, once create is empty.
## Resource Structure
```json theme={null}
{
"id": "789e0123-e45b-67d8-a901-234567890123",
"name": "Q3 product follow-ups",
"agentId": "456e7890-e12b-34d5-a678-901234567890",
"status": "RUNNING",
"scheduledAt": null,
"scheduleError": null,
"progress": {
"total": 42,
"queued": 30,
"inProgress": 3,
"completed": 8,
"failed": 1,
"cancelled": 0,
"unreachable": 0,
"completion": 0.21
},
"createdAt": "2026-08-20T09:00:00Z",
"updatedAt": "2026-08-20T11:42:00Z"
}
```
`scheduledAt` is when a `SCHEDULED` run starts dialling, `null` on one that launched immediately. `scheduleError` is why a scheduled run did not start at its time — see [Starting later](#starting-later).
The run carries no caller ID, retry policy or call window. Those are the agent's, and the agent is the single source of them.
## Required Scopes
| Operation | Required Scope |
| - | - |
| Get run | `read:runs` |
| List a run's conversations | `read:runs` and `read:conversations` |
| Create, launch, lifecycle | `write:runs` |
| Attach or detach contacts | `write:runs` and `read:contacts` |
| Delete run | `delete:runs` |
The run scopes are new and have **no legacy aliases** — runs had no public surface before, so there is nothing to stay compatible with. Keys that already existed were granted all three, and new keys are issued with all three, so there is nothing to add.
`delete:runs` is separate from `write:runs` deliberately: a key that may build and drive a run need not also be able to remove it.
The conversations drill-down needs `read:conversations` as well because its rows **are** conversation resources, with the same analysis and analytics `GET /conversations` returns. `read:interviews` satisfies that half, as it does everywhere else.
## Progress
Every conversation the run has produced lands in exactly one bucket, so the six counts sum to `total`:
| Bucket | What is in it |
| - | - |
| `queued` | Dispatched but not yet dialled |
| `inProgress` | Ringing or connected right now |
| `completed` | The call happened and finished |
| `failed` | Something went wrong on the call |
| `cancelled` | Closed out without being attempted |
| `unreachable` | The retry budget was spent without the contact ever picking up |
Two of them are worth reading carefully.
`inProgress` is **not** derived from the conversation's stored `status`. Nothing flips that row while a call is being placed and ringing, so a contact whose phone is ringing right now would otherwise report as queued. Such a call is moved out of `queued` rather than added on top, which is why the buckets still sum to `total`.
`unreachable` is its own bucket rather than part of `failed` because nothing malfunctioned — the contact simply never picked up within the retry budget. Folding the two together would also mean an entirely unreachable run never reached 100%.
`completion` is the fraction of conversations in a terminal state (`completed + failed + cancelled + unreachable`), between `0` and `1`.
## What a launch actually promises
A `2xx` from launch means the conversations are **queued, not dialled**. The provider is called later by the dispatch worker, outside your request, so:
* **per-contact outcomes never come back in the response.** They arrive as [`conversation.*` webhooks](/guides/webhooks), each carrying `runId`
* **the batch is 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`](/guides/error-handling#billing-errors), nothing is queued, and the run stays a draft. Partial admission was rejected because it turns one request into an outcome nobody can predict or undo — you cannot un-call the contacts who already went out
Admission is a **preflight, not a reservation**. Nothing is held, so two different runs launched
at the same moment can both pass and your balance can move between admission and the dial. What
protects the money is the per-call reservation before each individual call, which fails closed.
What admission buys you is a single refusal up front instead of a run that fails one call at a
time and reads like a telephony problem.
Rate limits do not bound any of this. Your API key's 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.
## Starting later
`POST /runs/{id}/launch` with a `scheduledAt` books the run instead of dispatching it: it moves to `SCHEDULED`, queues 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.
It fires through the same code an immediate launch runs, so a run that starts on Monday morning is indistinguishable from one launched by hand on Monday morning. It may begin up to a minute late.
Until it fires it is still an ordinary draft: attach more contacts, or cancel it and the appointment goes with it. Contacts attached in the meantime are **held rather than dialled** — a run booked for Monday has not started, so a contact added on Saturday waits and goes out with the rest.
**A booking is not a reservation.** The batch is admitted against billing when you schedule it
*and again when it fires*, and the second one decides. Nothing is held in between.
A run refused at its time stays `SCHEDULED` and is retried, with the reason in `scheduleError`.
Topping up is enough to make it go. But **that field is the only place the refusal is visible** —
a run whose `scheduledAt` has passed while its status is still `SCHEDULED` has been refused, and
nothing else will tell you.
## Lifecycle
```
DRAFT ────────────────▶ RUNNING ⇄ PAUSED
│ │ ▲
│ at scheduledAt │ │
└──▶ SCHEDULED ─────────┘ │
│
COMPLETED
│
attaching a contact │
reopens it ──────────┘
DRAFT | SCHEDULED | RUNNING | PAUSED ──▶ CANCELLED (terminal)
```
Cancel is reachable from every non-terminal status, not only from `PAUSED`. Cancelling a scheduled run clears its appointment as well as setting the status, so it never starts.
`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.
`CANCELLED` is terminal. `PAUSED` is reversible, and it is a real stop:
* **Nothing further is dialled**, including calls already queued when you paused. They are withdrawn from the dispatch queue, and anything the queue had already picked up is refused at the call itself.
* **The work is held, not discarded.** The conversations stay in place and `resume` puts back exactly what pause held — a contact already reached during the running window is not called again, and none of the waiting ones are lost.
* **A contact attached to a paused run is not called** either. The conversation is created, the run stays paused, and they go out with everything else on resume.
`CANCELLED` differs on the last two: it closes the un-dialled conversations out, and there is no way back. A call already connected when you cancel is left to finish; its retry is refused rather than rescheduled.
## Correlating a Conversation Back to Its Run
Every conversation carries a nullable `runId`:
```json theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"contactId": "contact-uuid",
"agentId": "agent-uuid",
"runId": "789e0123-e45b-67d8-a901-234567890123",
"status": "COMPLETED"
}
```
It is `null` for a conversation created on its own, and the run's id for one a run produced.
**The webhook payloads carry it too**, beside the ids they already send, so a `conversation.completed` handler attributes the call to its batch without reading the conversation back at all:
```json theme={null}
{
"event": "conversation.completed",
"data": {
"conversationId": "550e8400-e29b-41d4-a716-446655440000",
"contactId": "contact-uuid",
"runId": "789e0123-e45b-67d8-a901-234567890123",
"status": "COMPLETED"
}
}
```
Like the other payload ids, `runId` does not depend on the webhook's [vocabulary](/guides/webhooks#event-name-vocabulary) — it is always present, and `null` where there is no run.
## Monitoring a Run
```javascript theme={null}
// Reads a JSON body, or throws. Without a deadline a stalled request never settles, and
// parsing a non-2xx body would hand you an error envelope dressed as a result.
async function read(path) {
const response = await fetch(`https://api.instaview.sk${path}`, {
headers: { Authorization: `Bearer ${process.env.INSTAVIEW_API_KEY}` },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`GET ${path} failed: ${response.status} ${response.statusText}`);
}
return response.json();
}
const run = await read(`/runs/${runId}`);
console.log(`${Math.round(run.progress.completion * 100)}% done, ${run.progress.queued} still queued`);
```
Then drill in when you need per-contact detail:
```javascript theme={null}
const page = await read(`/runs/${runId}/conversations?limit=100`);
const unreachable = page.data.filter((conversation) => conversation.status === "UNREACHABLE");
```
Prefer webhooks to polling where you can: each conversation in a run fires the ordinary [`conversation.*` events](/guides/webhooks), and `runId` in the payload attributes it to the run without a request of your own.
## Company Isolation
You can only read runs belonging to your own API key's company. A run in another company returns `404 Not Found`, the same as one that does not exist — the API does not confirm that a resource you cannot read exists.
## Related Resources
Status and aggregate progress
The per-contact drill-down
What each conversation in a run carries
The agent a run dials with
# Sourcing
Source: https://docs.instaview.sk/guides/resources/sourcing
AI-powered asynchronous candidate sourcing and enrichment via the Public API
## Overview
The Sourcing API exposes InstaView's AI-driven candidate sourcing pipeline as a fully programmable, asynchronous REST interface. You can initiate sourcing runs, refine criteria mid-session, enrich shortlisted profiles to find phone numbers and emails, and embed beautifully styled candidate cards directly into your ATS or internal dashboards.
All sourcing operations are **asynchronous** — requests return immediately with a `requestId`, and results are delivered either via [webhooks](/guides/webhooks) or polled through a results endpoint.
## Asynchronous Request-Response Pattern
```mermaid theme={null}
sequenceDiagram
autonumber
participant Client as Client Application
participant API as InstaView API
participant Webhook as Your Webhook Endpoint
Client->>API: POST /sourcing (job title, description, location)
API-->>Client: 202 Accepted { requestId: "req_…" }
Note over API: Agent runs sourcing pipeline
in the background
alt Webhook delivery
API->>Webhook: POST (sourcing.completed payload)
Webhook-->>API: 200 OK
else Fallback polling
Client->>API: GET /sourcing/{requestId}/results
API-->>Client: 200 OK { status: "completed", candidates: […] }
end
```
## Required Scopes
| Operation | Required Scope |
| - | - |
| Initiate sourcing run | `sourcing:write` |
| Patch / re-run sourcing session | `sourcing:write` |
| Get sourcing results | `sourcing:read` |
| Initiate enrichment run | `enrich:write` |
| Get enrichment results | `enrich:write` |
| Render HTML candidate cards | `enrich:write` |
## Execution Pipeline
When a sourcing run is initiated, the sourcing agent executes a fully automated pipeline — no clarifying questions, no interactive steps:
The agent derives a complete Ideal Candidate Profile (ICP) from your `title`, `jobDescription`, `location`, and optional `query`. Skill expansion, title synonyms, location variants, and negative keywords are all populated automatically.
A tailored search query is compiled against InstaView's sourcing index. Skill expansions and localized terms maximize recall.
The search is executed against external platform indices. A strict 15-second timeout with 2 automatic retries applies — if the external index is unavailable, a circuit-breaker failover triggers immediately and a `sourcing.failed` webhook is dispatched.
Profiles are enriched sequentially. Failed individual profiles are isolated and marked unbilled — the batch continues with the remaining candidates.
The completed, ranked candidate list is delivered to your webhook endpoint or made available via `GET /sourcing/{id}/results`.
## Initiating a Sourcing Run
```bash cURL theme={null}
curl -X POST https://api.instaview.sk/sourcing \
-H "Authorization: Bearer sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"title": "Senior Staff ML Engineer",
"jobDescription": "We are seeking a Staff ML Engineer to design and scale our agentic coding infrastructure...",
"location": "Remote, USA",
"query": "Prioritize candidates with active contributions to LangChain or vLLM.",
"limit": 15,
"webhookId": "3f9a12c7-44b8-4d32-b71f-e29a7d1e0f5c"
}'
```
```javascript Node.js theme={null}
const response = await fetch('https://api.instaview.sk/sourcing', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.INSTAVIEW_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: 'Senior Staff ML Engineer',
jobDescription: 'We are seeking a Staff ML Engineer...',
location: 'Remote, USA',
limit: 15,
webhookId: '3f9a12c7-44b8-4d32-b71f-e29a7d1e0f5c',
}),
});
const { requestId } = await response.json();
console.log('Run started:', requestId); // "req_550e8400-…"
```
```python Python theme={null}
import os
import requests
resp = requests.post(
'https://api.instaview.sk/sourcing',
headers={
'Authorization': f'Bearer {os.environ["INSTAVIEW_API_KEY"]}',',
'Content-Type': 'application/json',
},
json={
'title': 'Senior Staff ML Engineer',
'jobDescription': 'We are seeking a Staff ML Engineer...',
'location': 'Remote, USA',
'limit': 15,
'webhookId': '3f9a12c7-44b8-4d32-b71f-e29a7d1e0f5c',
},
)
data = resp.json()
print('Run started:', data['requestId'])
```
### Response (202 Accepted)
```json theme={null}
{
"success": true,
"requestId": "req_9f3b827e-8c31-4bda-a7a2-b2d99d14f4e2",
"status": "processing",
"message": "Sourcing run accepted and queued for processing.",
"webhookId": "3f9a12c7-44b8-4d32-b71f-e29a7d1e0f5c",
"createdAt": "2026-05-20T13:20:45.000Z"
}
```
## Polling for Results
If you do not use webhooks, poll `GET /sourcing/{requestId}/results` until `status` transitions from `"processing"` to `"completed"` or `"failed"`.
```javascript theme={null}
async function pollResults(requestId) {
while (true) {
const res = await fetch(`https://api.instaview.sk/sourcing/${requestId}/results`, {
headers: { 'Authorization': `Bearer ${process.env.INSTAVIEW_API_KEY}` },
});
const body = await res.json();
if (body.status === 'completed') {
return body.candidates;
}
if (body.status === 'failed') {
throw new Error(body.errorMessage);
}
// Still processing — wait before polling again
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
```
Avoid polling more frequently than once every 5 seconds. Excessive polling can trigger the `GET /sourcing/*/results` rate limit (120 requests/minute per API key).
## Refining a Sourcing Session
After a run completes, use `PATCH /sourcing/{requestId}` to refine criteria or request additional candidates from the same session context. At least one of `query` or `limit` must be provided.
A PATCH request is only accepted after the current run transitions to `"completed"` or `"failed"`. If a run is still `"processing"`, the request returns `409 Conflict`.
```json theme={null}
{
"query": "Focus on startup founders with Kubernetes experience.",
"limit": 5
}
```
The agent applies the updated `query` as a context patch and then evaluates `limit` additional profiles — all in a single asynchronous pass.
## Enriching Candidates
Enrichment performs deep profile analysis including **email discovery**. It is fully asynchronous: submit profile IDs, receive a `requestId`, and poll `GET /sourcing/enrich/{requestId}/results` or await the `enrich.completed` webhook.
```bash theme={null}
curl -X POST https://api.instaview.sk/sourcing/enrich \
-H "Authorization: Bearer sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"profileIds": ["prof_a9238f82-a723", "prof_e8321c12-b912"],
"webhookId": "3f9a12c7-44b8-4d32-b71f-e29a7d1e0f5c"
}'
```
### Response (202 Accepted)
```json theme={null}
{
"success": true,
"requestId": "enr_550e8400-e29b-41d4-a716-446655440001",
"status": "processing",
"message": "Enrichment run accepted and queued for processing.",
"createdAt": "2026-05-20T13:21:00.000Z"
}
```
Each successfully enriched profile consumes **1 credit**. Profiles that fail to enrich are not billed. For prepaid accounts, your balance must cover the full batch size upfront.
### Enrichment Resilience
Individual profile failures within a batch are isolated: if one profile fails to enrich, the system marks it as failed, skips billing for it, and continues processing the rest of the batch.
## Rendering HTML Candidate Cards
Generate self-contained, embeddable HTML profile cards for your ATS or dashboards. This endpoint is **synchronous** — it returns immediately with rendered cards.
```bash theme={null}
curl -X POST https://api.instaview.sk/sourcing/render-html \
-H "Authorization: Bearer sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"profileIds": ["prof_a9238f82-a723"],
"theme": "dark"
}'
```
Supported themes: `"dark"` (default) and `"light"`.
## Webhook Events
Configure a [WebhookConfig](/guides/webhooks) and pass its `id` as `webhookId` when initiating runs. The webhook must subscribe to at least one of the relevant event types.
### Sourcing Events
| Registration Key (`events[]`) | Payload Label (`event`) | Triggered When |
| - | - | - |
| `"SOURCING_COMPLETED"` | `"sourcing.completed"` | Sourcing run finishes with scored candidates |
| `"SOURCING_FAILED"` | `"sourcing.failed"` | Sourcing run encounters an unrecoverable error |
### Enrichment Events
| Registration Key (`events[]`) | Payload Label (`event`) | Triggered When |
| - | - | - |
| `"ENRICH_COMPLETED"` | `"enrich.completed"` | Enrichment run completes (partial success is still completed) |
| `"ENRICH_FAILED"` | `"enrich.failed"` | Enrichment run encounters an unrecoverable error |
A webhook config that subscribes to a mix of event types (e.g. `["ANALYSIS_COMPLETED", "SOURCING_COMPLETED", "ENRICH_COMPLETED"]`) is fully valid — all subscriptions are honored independently.
## Billing & Credits
* **Sourcing**: 1 credit per successfully sourced and matched candidate, billed based on the `limit` parameter.
* **Enrichment**: 1 credit per successfully enriched profile (failed profiles are not billed).
* **Prepaid accounts**: balance must cover the full `limit` before a run starts. Insufficient funds → `402 Payment Required`.
* **Postpaid accounts**: run immediately regardless of balance; invoiced at end of the billing cycle.
## Multi-Tenant Security
All sourcing resources (`requestId`s, candidate lists, enrichment results) are strictly bound to the `companyId` associated with the authenticating API key:
* An API key from Company A **cannot** access, patch, or enrich resources belonging to Company B.
* Attempting to access another company's `requestId` returns `404 REQUEST_NOT_FOUND` — not `403` — to prevent resource enumeration.
* A `webhookId` submitted with a sourcing run must belong to the **same company** as the API key; mismatched ownership returns `403 WEBHOOK_COMPANY_MISMATCH`.
## Rate Limits
| Endpoint | Rate Limit |
| - | - |
| `POST /sourcing` | 10 requests / minute |
| `PATCH /sourcing/{id}` | 30 requests / minute |
| `GET /sourcing/{id}/results` | 120 requests / minute |
| `POST /sourcing/enrich` | 50 requests / minute |
| `GET /sourcing/enrich/{id}/results` | 120 requests / minute |
| `POST /sourcing/render-html` | 50 requests / minute |
## Error Codes
| HTTP Status | Error Code | Description |
| - | - | - |
| `400` | `INVALID_PAYLOAD` | Missing required fields (`title` or `jobDescription`) or invalid syntax |
| `400` | `INVALID_PATCH_PAYLOAD` | PATCH body omits both `query` and `limit` |
| `401` | `UNAUTHORIZED` | API key is missing, expired, or deactivated |
| `402` | `INSUFFICIENT_FUNDS` | Prepaid account balance too low to cover the run's `limit` |
| `403` | `INSUFFICIENT_SCOPES` | API key does not have the required scope |
| `403` | `WEBHOOK_COMPANY_MISMATCH` | Provided `webhookId` belongs to a different company |
| `404` | `REQUEST_NOT_FOUND` | `requestId` does not exist, has expired, or belongs to another company |
| `404` | `WEBHOOK_NOT_FOUND` | Provided `webhookId` does not exist or is inaccessible |
| `409` | `RUN_IN_PROGRESS` | PATCH attempted while a run is still `"processing"` |
| `422` | `WEBHOOK_EVENT_NOT_SUBSCRIBED` | Webhook config has no overlap with sourcing or enrichment event types |
| `429` | `RATE_LIMIT_EXCEEDED` | Request rate exceeded sliding window limits |
| `503` | `PROVIDER_TEMPORARILY_UNAVAILABLE` | External sourcing platforms experiencing outages |
# Webhooks
Source: https://docs.instaview.sk/guides/resources/webhooks
Configure and manage webhook endpoints for real-time event notifications
## Overview
The Webhooks API allows you to configure endpoints that receive real-time HTTP notifications when events occur in your InstaView account. Webhooks provide an event-driven alternative to polling, enabling immediate integration with your systems.
## Resource Structure
```json theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"apiKeyId": "api-key-uuid",
"companyId": "company-uuid",
"url": "https://api.example.com/webhooks/instaview",
"name": "Production Webhook",
"description": "Receives conversation completion notifications for ATS integration",
"headers": [
{ "name": "Authorization", "isMasked": true },
{ "name": "X-Custom-Header", "isMasked": false }
],
"events": ["conversation.completed", "analysis.completed"],
"isActive": true,
"maxRetries": 3,
"timeoutMs": 30000,
"consecutiveFailures": 0,
"circuitOpenedAt": null,
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
```
## Required Scopes
| Operation | Required Scope |
| - | - |
| List webhooks | `read:webhooks` |
| Get webhook by ID | `read:webhooks` |
| Create webhook | `write:webhooks` |
| Update webhook | `write:webhooks` |
| Delete webhook | `write:webhooks` |
| Test webhook (ping) | `write:webhooks` |
| Reset circuit breaker | `write:webhooks` |
## Creating Webhooks
### Basic Webhook
```bash cURL theme={null}
curl -X POST https://api.instaview.sk/webhooks \
-H "Authorization: Bearer sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/webhooks/instaview",
"events": ["CONVERSATION_COMPLETED", "ANALYSIS_COMPLETED"]
}'
```
```javascript Node.js theme={null}
const webhook = await fetch('https://api.instaview.sk/webhooks', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.INSTAVIEW_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: 'https://api.example.com/webhooks/instaview',
events: ["CONVERSATION_COMPLETED", "ANALYSIS_COMPLETED"]
})
});
const data = await webhook.json();
// IMPORTANT: Store this securely - only shown once!
console.log('Signing secret:', data.signingSecret);
```
```python Python theme={null}
response = requests.post(
'https://api.instaview.sk/webhooks',
headers={
'Authorization': f'Bearer {os.environ["INSTAVIEW_API_KEY"]}',
'Content-Type': 'application/json'
},
json={
'url': 'https://api.example.com/webhooks/instaview',
'events': ['CONVERSATION_COMPLETED', 'ANALYSIS_COMPLETED']
}
)
data = response.json()
# IMPORTANT: Store this securely - only shown once!
print(f'Signing secret: {data["signingSecret"]}')
```
### Comprehensive Webhook Configuration
```javascript theme={null}
const comprehensiveWebhook = {
// Required: Endpoint URL
url: 'https://api.example.com/webhooks/instaview',
// Required: Event types to subscribe to
events: [
"ANALYSIS_COMPLETED",
"ANALYSIS_FAILED",
"PING", // for testing
"CONVERSATION_COMPLETED",
"CONVERSATION_FAILED",
"CONVERSATION_STARTED",
"CONVERSATION_RESCHEDULED",
"CONVERSATION_CANCELLED"
],
// Optional: Human-readable name
name: 'Production Analysis Webhook',
// Optional: Description
description: 'Receives all conversation and analysis events for our ATS integration',
// Optional: Company ID for per-company webhooks (ATS integrations)
companyId: 'company-uuid', // If omitted, webhook receives events for all companies
// Optional: Custom headers for authentication
headers: [
{ name: 'Authorization', value: 'Bearer your-internal-token' },
{ name: 'X-Source', value: 'instaview' }
]
};
const response = await createWebhook(comprehensiveWebhook);
// Store the signing secret securely!
await secretsManager.store('INSTAVIEW_WEBHOOK_SECRET', response.signingSecret);
```
**Important**: The `signingSecret` is only returned once during webhook
creation. Store it securely immediately. If you lose it, you must delete and
recreate the webhook.
## Listing Webhooks
### List All Webhooks
```javascript theme={null}
async function listWebhooks(companyId) {
const url = companyId
? `https://api.instaview.sk/webhooks?companyId=${companyId}`
: 'https://api.instaview.sk/webhooks';
const response = await fetch(url, {
headers: { 'Authorization': `Bearer ${apiKey}` }
});
return await response.json();
}
// List all webhooks (includes both company-specific and global webhooks)
const { data: webhooks, total } = await listWebhooks();
console.log(`Found ${total} webhook configurations`);
// List webhooks for a specific company (excludes global webhooks)
// Only returns webhooks that have the specified companyId
const { data: companyWebhooks } = await listWebhooks('company-uuid');
console.log(`Found ${companyWebhooks.length} webhooks for company`);
```
### Monitor Webhook Health
```javascript theme={null}
async function checkWebhookHealth() {
const { data: webhooks } = await listWebhooks();
for (const webhook of webhooks) {
const status = {
name: webhook.name || webhook.id,
active: webhook.isActive,
failures: webhook.consecutiveFailures,
circuitOpen: !!webhook.circuitOpenedAt
};
if (webhook.consecutiveFailures > 0) {
console.warn(`⚠️ ${status.name}: ${webhook.consecutiveFailures} failures`);
}
if (webhook.circuitOpenedAt) {
console.error(`🔴 ${status.name}: Circuit breaker open since ${webhook.circuitOpenedAt}`);
}
}
}
```
## Updating Webhooks
### Partial Update
```javascript theme={null}
async function updateWebhook(webhookId, updates) {
const response = await fetch(
`https://api.instaview.sk/webhooks/${webhookId}`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(updates)
}
);
return await response.json();
}
// Update URL
await updateWebhook('webhook-uuid', {
url: 'https://api.example.com/webhooks/instaview-v2'
});
// Add more events
await updateWebhook('webhook-uuid', {
events: ["ANALYSIS_COMPLETED", "ANALYSIS_FAILED", "PING", "CONVERSATION_COMPLETED", "CONVERSATION_FAILED", "CONVERSATION_STARTED", "CONVERSATION_RESCHEDULED", "CONVERSATION_CANCELLED"] // All conversation & analysis events
});
// Disable webhook
await updateWebhook('webhook-uuid', {
isActive: false
});
```
### Update Custom Headers
```javascript theme={null}
// Replace all custom headers
await updateWebhook('webhook-uuid', {
headers: [
{ name: 'Authorization', value: 'Bearer new-token' },
{ name: 'X-Environment', value: 'production' }
]
});
```
When updating headers, the entire headers array is replaced. Include all
headers you want to keep.
## Event Types
### Event Type Reference
Use the **constant** when registering a webhook (the `events` array). The **label** is what appears in the `event` field of the payload you receive.
| Constant | Payload label | Description | Payload |
| - | - | - | - |
| `ANALYSIS_COMPLETED` | `analysis.completed` | Analysis finished successfully | Conversation, call attempt, contact, job IDs, analysis results |
| `ANALYSIS_FAILED` | `analysis.failed` | Analysis processing failed | IDs + error message |
| `PING` | `ping` | Test event for connectivity | Test message |
| `CONVERSATION_COMPLETED` | `conversation.completed` | The call finished and analysis is available | Conversation, call attempt, contact, job IDs |
| `CONVERSATION_FAILED` | `conversation.failed` | A technical failure prevented the call | IDs + error message |
| `CONVERSATION_STARTED` | `conversation.started` | A call attempt began | Conversation, call attempt, contact, job IDs |
| `CONVERSATION_RESCHEDULED` | `conversation.rescheduled` | A follow-up call attempt has been scheduled | Previous + next attempt, reason, retry counters |
| `CONVERSATION_CANCELLED` | `conversation.cancelled` | The conversation ended without completing | Reason, attempts made, last call attempt ID |
| `SOURCING_COMPLETED` | `sourcing.completed` | Sourcing run finished with scored candidates | Sourcing run IDs and results |
| `SOURCING_FAILED` | `sourcing.failed` | Sourcing run terminated with an error | IDs + error message |
| `ENRICH_COMPLETED` | `enrich.completed` | Enrichment run completed | Enrichment run IDs and results |
| `ENRICH_FAILED` | `enrich.failed` | Enrichment run terminated with an error | IDs + error message |
The five conversation events answer to two spellings. `INTERVIEW_COMPLETED` and its siblings
register the same subscription as the `CONVERSATION_*` constants above, and the payload label
you receive is `interview.completed` on a webhook whose `vocabulary` is `LEGACY` — which every
webhook created before that option existed is. New webhooks default to `NEUTRAL` and receive
the labels shown here. See [Event name
vocabulary](/guides/webhooks#event-name-vocabulary).
### Event Selection Strategy
Subscribe only to completion events:
```javascript theme={null}
{
events: ["CONVERSATION_COMPLETED", "ANALYSIS_COMPLETED"]
}
```
**Use case**: Simple integrations that only need final results
Include failure events for error handling:
```javascript theme={null}
{
events: ["ANALYSIS_COMPLETED", "ANALYSIS_FAILED", "CONVERSATION_COMPLETED", "CONVERSATION_FAILED"]
}
```
**Use case**: Production integrations that need to handle errors
All events for full visibility:
```javascript theme={null}
{
events: ["ANALYSIS_COMPLETED", "ANALYSIS_FAILED", "PING", "CONVERSATION_COMPLETED", "CONVERSATION_FAILED", "CONVERSATION_STARTED", "CONVERSATION_RESCHEDULED", "CONVERSATION_CANCELLED"] // All conversation & analysis events
}
```
**Use case**: Real-time dashboards or detailed audit trails
## Circuit Breaker
The circuit breaker protects both systems from cascading failures:
### How It Works
Each failed delivery increments `consecutiveFailures`
After threshold failures, `circuitOpenedAt` is set and deliveries stop
No new deliveries are attempted while circuit is open
Use the reset endpoint to re-enable after fixing issues
### Monitoring Circuit State
```javascript theme={null}
async function monitorCircuitBreakers() {
const { data: webhooks } = await listWebhooks();
for (const webhook of webhooks) {
if (webhook.circuitOpenedAt) {
console.error(`Circuit OPEN: ${webhook.name}`);
console.error(` Opened at: ${webhook.circuitOpenedAt}`);
console.error(` Failures: ${webhook.consecutiveFailures}`);
// Alert your team
await sendAlert({
type: 'webhook_circuit_open',
webhook: webhook.name,
url: webhook.url,
openedAt: webhook.circuitOpenedAt
});
}
}
}
```
### Resetting Circuit Breaker
```javascript theme={null}
async function resetCircuitBreaker(webhookId) {
const response = await fetch(
`https://api.instaview.sk/webhooks/${webhookId}/reset-circuit`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}` }
}
);
const data = await response.json();
console.log(`Circuit reset. Active: ${data.isActive}`);
return data;
}
```
## Testing Webhooks
### Send Test Ping
```javascript theme={null}
async function testWebhook(webhookId) {
const response = await fetch(
`https://api.instaview.sk/webhooks/${webhookId}/test`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}` }
}
);
const data = await response.json();
if (data.success) {
console.log('✅ Webhook test successful');
console.log(` Delivery ID: ${data.deliveryId}`);
console.log(` HTTP Status: ${data.httpStatus}`);
console.log(` Duration: ${data.durationMs}ms`);
} else {
console.error('❌ Webhook test failed');
console.error(` Message: ${data.message}`);
}
return data;
}
```
The webhook must be subscribed to the `ping` event (type 2) for testing to
work.
### Test Response Structure
```json theme={null}
{
"success": true,
"deliveryId": "delivery-uuid",
"httpStatus": 200,
"durationMs": 156,
"message": "Webhook test successful!"
}
```
Or on failure:
```json theme={null}
{
"success": false,
"message": "Webhook test failed: Connection timeout"
}
```
## Deleting Webhooks
### Delete a Webhook
```javascript theme={null}
async function deleteWebhook(webhookId) {
const response = await fetch(
`https://api.instaview.sk/webhooks/${webhookId}`,
{
method: 'DELETE',
headers: { 'Authorization': `Bearer ${apiKey}` }
}
);
if (response.status === 204) {
console.log('Webhook deleted successfully');
}
}
```
Deleting a webhook immediately stops all deliveries. Pending deliveries will
not be sent.
## Custom Headers
### Supported Headers
You can add custom headers for authentication or routing:
```javascript theme={null}
{
headers: [
// Authentication
{ name: 'Authorization', value: 'Bearer your-token' },
{ name: 'X-API-Key', value: 'your-api-key' },
// Custom headers
{ name: 'X-Source', value: 'instaview' },
{ name: 'X-Environment', value: 'production' }
]
}
```
### Sensitive Headers
The following headers are automatically encrypted at rest:
* `Authorization`
* `X-API-Key`
* `X-Secret`
* `API-Key`
When listing webhooks, these headers show `isMasked: true` and their values are hidden.
### Header Limits
* Maximum 50 custom headers per webhook
* Header names: 1-255 characters
* Header values: 1-4096 characters
## Configuration Fields
The HTTPS endpoint URL to receive webhook notifications (max 2048 characters)
Array of event type constants to subscribe to (see Event Type Reference)
Human-readable name for the webhook (max 255 characters)
Description of the webhook's purpose
Custom headers to include in requests (max 50 headers)
Whether the webhook is active (default: true, can be updated)
## Response Fields
Unique identifier for the webhook configuration
The API key ID this webhook belongs to
Company ID this webhook is associated with. Null if webhook receives events for all companies (global webhook).
Maximum retry attempts for failed deliveries (system-configured)
Request timeout in milliseconds (system-configured, default 30000)
Current count of consecutive delivery failures
ISO 8601 timestamp when circuit breaker was triggered (null if closed)
HMAC signing secret (only returned on creation, hex-encoded)
## Error Scenarios
Errors answer with the flat body described in [Error Handling](/guides/error-handling): a
`statusCode`, a `message`, a `traceId`, and — on a rejected request body — an `errors` array
with one entry per rejected field.
```json theme={null}
{
"statusCode": 400,
"message": "Validation failed",
"errors": ["url must be a valid URL"],
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4"
}
```
The rejection names the canonical constants, then names the neutral spellings that register
the same five conversation events:
```json theme={null}
{
"statusCode": 400,
"message": "Validation failed",
"errors": [
"each value in events must be one of the following values: ANALYSIS_COMPLETED, ANALYSIS_FAILED, PING, INTERVIEW_COMPLETED, INTERVIEW_FAILED, INTERVIEW_STARTED, INTERVIEW_RESCHEDULED, INTERVIEW_CANCELLED, SOURCING_COMPLETED, SOURCING_FAILED, ENRICH_COMPLETED, ENRICH_FAILED. Conversation events accept either spelling, so CONVERSATION_COMPLETED, CONVERSATION_FAILED, CONVERSATION_STARTED, CONVERSATION_RESCHEDULED, CONVERSATION_CANCELLED are also valid and register the same events as their INTERVIEW_* equivalents."
],
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4"
}
```
```json theme={null}
{
"statusCode": 404,
"message": "Webhook configuration not found",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4"
}
```
```json theme={null}
{
"statusCode": 403,
"message": "Access denied to this webhook configuration",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4"
}
```
Not an error status: `POST /webhooks/{id}/test` answers `200` and reports the refusal in
the test result itself.
```json theme={null}
{
"success": false,
"message": "Webhook is not subscribed to ping events. Add event type 'ping' to the webhook configuration."
}
```
## Common Patterns
### Webhook Setup Flow
```javascript theme={null}
async function setupWebhook(config) {
// 1. Create webhook
const response = await createWebhook({
url: config.url,
name: config.name,
events: ["PING", "CONVERSATION_COMPLETED", "ANALYSIS_COMPLETED"],
headers: config.headers
});
// 2. Store signing secret securely
await secretsManager.store(
`WEBHOOK_SECRET_${response.id}`,
response.signingSecret
);
// 3. Test connectivity
const testResult = await testWebhook(response.id);
if (!testResult.success) {
// Clean up if test fails
await deleteWebhook(response.id);
throw new Error(`Webhook test failed: ${testResult.message}`);
}
// 4. Remove ping event if not needed for production
await updateWebhook(response.id, {
events: ["CONVERSATION_COMPLETED", "ANALYSIS_COMPLETED"]
});
return response;
}
```
### Health Check Automation
```javascript theme={null}
async function webhookHealthCheck() {
const { data: webhooks } = await listWebhooks();
const issues = [];
for (const webhook of webhooks) {
// Check for failures
if (webhook.consecutiveFailures > 5) {
issues.push({
type: 'high_failures',
webhook: webhook.name,
failures: webhook.consecutiveFailures
});
}
// Check for open circuits
if (webhook.circuitOpenedAt) {
issues.push({
type: 'circuit_open',
webhook: webhook.name,
openedAt: webhook.circuitOpenedAt
});
}
// Check for inactive webhooks
if (!webhook.isActive) {
issues.push({
type: 'inactive',
webhook: webhook.name
});
}
}
return issues;
}
```
## Best Practices
Name webhooks clearly (e.g., "Production ATS Sync", "Staging Test")
Set up alerts for `consecutiveFailures > 0`
Always test webhooks with ping before relying on them
Use a secrets manager for signing secrets
## Next Steps
Learn about webhook payloads and signature verification
Complete webhook endpoint documentation
Understand conversation events
Configure webhook permissions
# Scopes and Permissions
Source: https://docs.instaview.sk/guides/scopes-and-permissions
Understanding scope-based access control in the InstaView API
## Overview
InstaView uses scope-based permissions to provide granular access control. Each API key has a set of scopes that determine which operations it can perform on which resources.
## Permission Model
### Three-Level Access Control
Every resource type supports three permission levels:
**GET** operations - List resources - View resource details - Search and
filter
**POST** and **PATCH** operations - Create resources - Update resources -
Modify configurations
**DELETE** operations - Soft delete resources - Remove associations -
Archive data
### Scope Format
Scopes follow the format: `:`
```
read:jobs // Can list and view jobs
write:contacts // Can create and update contacts
delete:agents // Can delete agents
```
### Two names per scope
Conversation and contact scopes each answer to two names. InstaView's API began in hiring, so
its scopes were written in hiring nouns; agents now run sales, support and operations calls,
where `interview` and `candidate` describe the wrong thing.
| Current name | Legacy alias |
| - | - |
| `read:conversations` | `read:interviews` |
| `write:conversations` | `write:interviews` |
| `delete:conversations` | `delete:interviews` |
| `read:contacts` | `read:candidates` |
| `write:contacts` | `write:candidates` |
| `delete:contacts` | `delete:candidates` |
**They are the same permission.** Holding either name satisfies a requirement for either, in
both directions — a key with `read:interviews` reads `/conversations`, and a key with
`read:conversations` reads `/interviews`.
**There is nothing to migrate.** Your existing key keeps the scopes it already has and keeps
working, with no deadline attached. Keys created from now on are issued with the current
names, and the dashboard offers only those. Every other scope family — `jobs`, `agents`,
`companies`, `billing`, `webhooks` — is unchanged.
The tables below list the current name of each scope. Where a legacy alias exists it is named
beside it, so you can recognise the scopes on a key you already have.
## Available Scopes
| Scope | Permissions | Operations |
| - | - | - |
| `read:jobs` | View jobs | `GET /jobs`, `GET /jobs/{id}` |
| `write:jobs` | Create/update jobs | `POST /jobs`, `PATCH /jobs/{id}` |
| `delete:jobs` | Delete jobs | `DELETE /jobs/{id}` |
**Common Combinations**:
```json theme={null}
["read:jobs"] // Read-only
["read:jobs", "write:jobs"] // Create and read
["read:jobs", "write:jobs", "delete:jobs"] // Full access
```
| Scope | Legacy alias | Permissions | Operations |
| - | - | - | - |
| `read:contacts` | `read:candidates` | View contacts | `GET /contacts`, `GET /contacts/{id}` |
| `write:contacts` | `write:candidates` | Create/update | `POST /contacts`, `PATCH /contacts/{id}` |
| `delete:contacts` | `delete:candidates` | Delete contacts | `DELETE /contacts/{id}` |
Contact **fields** ride on these scopes too, both halves of them: `read:contacts` reads
the catalog (`GET /contact-fields`) and one contact's values
(`GET /contacts/{id}`); `write:contacts` defines, changes and removes fields
(`POST`/`PATCH`/`DELETE /contact-fields`) and sets values
(`POST` / `PATCH /contacts`), including `?createMissingFields=true`.
There is no `delete:` requirement for removing a field definition. It removes schema,
not a contact, and the values already stored deliberately survive it — requiring
`delete:contacts` would imply the opposite.
**Common Combinations**:
```json theme={null}
["read:contacts", "read:jobs"] // View contacts and their jobs
["read:contacts", "write:contacts", "read:jobs"] // Manage contacts
```
| Scope | Legacy alias | Permissions | Operations |
| - | - | - | - |
| `read:conversations` | `read:interviews` | View conversations | `GET /conversations`, `GET /conversations/{id}` |
| `write:conversations` | `write:interviews` | Schedule conversations | `POST /conversations` |
| `delete:conversations` | `delete:interviews` | Delete conversations | `DELETE /conversations/{id}` |
**Common Combinations**:
```json theme={null}
["read:conversations"] // View results only
["read:conversations", "write:conversations", "read:contacts", "read:agents"] // Full conversation management
["read:conversations", "write:conversations", "delete:conversations", "read:contacts", "read:agents"] // Full access
```
| Scope | Permissions | Operations |
| - | - | - |
| `read:runs` | View a run and its conversations | `GET /runs/{id}`, `GET /runs/{id}/conversations` |
| `write:runs` | Create, attach, detach and drive the lifecycle | `POST /runs`, `POST /runs/{id}/contacts`, `DELETE /runs/{id}/contacts/{contactId}`, `POST /runs/{id}/launch`, `POST /runs/{id}/pause`, `POST /runs/{id}/resume`, `POST /runs/{id}/cancel` |
| `delete:runs` | Delete a run | `DELETE /runs/{id}` |
**New scopes, no legacy aliases, and nothing to migrate.** Runs had no public surface
before, so there is nothing to stay compatible with. Keys that already existed were granted
all three, and new keys are issued with all three, so there is nothing for you to add.
`delete:runs` exists for one operation and nothing else, so a key that may drive a run to
completion need not also be able to remove it.
Two operations need a second scope, because they reach data that is not the run:
`GET /runs/{id}/conversations` needs `read:conversations`, and `POST /runs/{id}/contacts`
needs `read:contacts`.
**Common Combinations**:
```json theme={null}
["read:runs"] // Aggregate progress only
["read:runs", "read:conversations"] // Progress plus per-contact detail
["read:runs", "write:runs", "read:contacts"] // Build and drive runs
["read:runs", "write:runs", "delete:runs", "read:contacts"] // Full access
```
| Scope | Permissions | Operations |
| - | - | - |
| `read:agents` | View agents | `GET /agents`, `GET /agents/{id}`, `GET /agents/{id}/knowledge` |
| `write:agents` | Create/update | `POST /agents`, `PATCH /agents/{id}`, `POST /agents/{id}/knowledge`, `POST /agents/{id}/knowledge/{documentId}/complete`, `PATCH /agents/{id}/knowledge/{documentId}` |
| `delete:agents` | Delete agents | `DELETE /agents/{id}`, `DELETE /agents/{id}/knowledge/{documentId}` |
An agent's knowledge base has no scopes of its own — it is part of the agent, so the
scope you already hold for the agent covers its documents.
**Common Combinations**:
```json theme={null}
["read:agents"] // View agent templates
["read:agents", "write:agents"] // Manage agent configurations and knowledge bases
```
| Scope | Permissions | Operations |
| - | - | - |
| `read:companies` | View company | `GET /companies/{id}` |
| `write:companies` | Update company | `PATCH /companies/{id}` |
| - | No delete | Companies cannot be deleted via API |
**Note**: Regular API keys can only access their own company. ATS keys can access
multiple companies.
| Scope | Permissions | Operations |
| - | - | - |
| `read:billing` | View billing info | `GET /billing/usage` |
| - | No write | Billing changes must be made via dashboard |
| - | No delete | Billing data cannot be deleted |
**Read-only resource** for monitoring usage and subscription status.
| Scope | Permissions | Operations |
| - | - | - |
| `read:webhooks` | View webhooks | `GET /webhooks`, `GET /webhooks/{id}` |
| `write:webhooks` | Create/update webhooks | `POST /webhooks`, `PATCH /webhooks/{id}`, `DELETE /webhooks/{id}`, `POST /webhooks/{id}/test`, `POST /webhooks/{id}/reset-circuit` |
| - | No delete scope | Deletion uses `write:webhooks` scope |
**Common Combinations**:
```json theme={null}
["read:webhooks"] // View webhook configurations
["read:webhooks", "write:webhooks"] // Full webhook management
```
## Scope Validation
### Request-Time Validation
Every API request validates the required scopes:
```javascript theme={null}
// Request
POST /jobs
Authorization: Bearer sk_xxx (scopes: ["read:jobs"])
// Response: 403 Forbidden
{
"message": "Insufficient permissions: Required scope write:jobs not found",
"error": "Forbidden",
"statusCode": 403
}
```
### Validation Flow
API key is extracted from `Authorization: Bearer` header
Key is validated (not revoked, not expired, not suspended)
Request's required scope is compared against key's scopes
Verify resource belongs to key's company (if applicable)
If all checks pass, request is processed
## Scope Strategy
### By Use Case
```json theme={null}
{
"name": "Analytics Dashboard",
"scopes": [
"read:jobs",
"read:contacts",
"read:conversations",
"read:billing"
]
}
```
**Rationale**: Dashboard only needs to view data, never modify it.
```json theme={null}
{
"name": "ATS Contact Sync",
"scopes": [
"read:jobs",
"read:contacts",
"write:contacts"
]
}
```
**Rationale**: a sync tool creates and updates contacts and reads job info; it should not delete or modify jobs.
```json theme={null}
{
"name": "Conversation Automation",
"scopes": [
"read:contacts",
"read:agents",
"read:conversations",
"write:conversations"
]
}
```
**Rationale**: scheduling calls automatically needs read access to contacts and agents, and write access to create conversations.
```json theme={null}
{
"name": "Primary Integration",
"scopes": [
"read:jobs",
"write:jobs",
"delete:jobs",
"read:contacts",
"write:contacts",
"delete:contacts",
"read:conversations",
"write:conversations",
"delete:conversations",
"read:agents",
"write:agents",
"delete:agents",
"read:companies",
"write:companies",
"read:billing",
"read:webhooks",
"write:webhooks"
]
}
```
**Rationale**: Main application integration needs full access to all resources.
```json theme={null}
{
"name": "Webhook Configuration",
"scopes": [
"read:webhooks",
"write:webhooks"
]
}
```
**Rationale**: Dedicated key for managing webhook configurations only.
### Least Privilege Principle
Always grant the minimum scopes needed:
* **Reduces Attack Surface**: Compromised keys have limited damage potential
* **Prevents Accidents**: Applications can't accidentally delete data they shouldn't
* **Audit Trail**: Scope requirements make it clear what each integration does
* **Compliance**: Many regulations require least-privilege access
A webhook handler that processes conversation completion events:
❌ **Too many scopes**:
```json theme={null}
["read:conversations", "write:conversations", "read:contacts", "write:contacts"]
```
✅ **Minimal scopes**:
```json theme={null}
["read:conversations", "read:contacts"]
```
The webhook only needs to read conversations, not create or modify them.
A reporting tool that generates analytics:
❌ **Includes write scopes**:
```json theme={null}
["read:jobs", "write:jobs", "read:contacts", "write:contacts"]
```
✅ **Read-only**:
```json theme={null}
["read:jobs", "read:contacts", "read:conversations", "read:billing"]
```
Reporting tools should never have write or delete permissions.
## Scope Dependencies
Some operations require multiple scopes due to resource relationships:
### Creating a Contact
```javascript theme={null}
POST /contacts
{
"jobId": "job-uuid",
"firstName": "Jane",
"lastName": "Doe"
}
```
**Required Scopes**:
* `write:contacts` - To create the contact
* `read:jobs` - To validate the job exists and belongs to your company
### Creating a Conversation
```javascript theme={null}
POST /conversations
{
"contactId": "contact-uuid",
"agentId": "agent-uuid"
}
```
**Required Scopes**:
* `write:conversations` - To create the conversation
* `read:contacts` - To validate contact ownership
* `read:agents` - To validate agent ownership
### Listing Contacts with a Job Filter
```javascript theme={null}
GET /contacts?jobId=job-uuid
```
**Required Scopes**:
* `read:contacts` - To list contacts
* `read:jobs` - To filter by job
## Cross-Resource Operations
### Inline Resources
Some operations allow creating inline resources:
```javascript theme={null}
// Create a conversation with an inline contact
POST /conversations
{
"contact": {
"jobId": "job-uuid",
"firstName": "Jane",
"lastName": "Doe",
"email": "jane@example.com",
"gdprExpiryDate": "2026-11-16T12:00:00Z"
},
"agentId": "agent-uuid"
}
```
**Required Scopes**:
* `write:conversations` - Primary operation
* `write:contacts` - To create the inline contact
* `read:agents` - To validate agent
* `read:jobs` - To validate job
Inline resources (contact, job, agent) are created as part of the parent operation and need
the same scopes as creating them directly. The XOR rule means you send either an existing id
or an inline definition, never both.
## Company Isolation
All scopes are enforced within company boundaries:
### Regular API Keys
```javascript theme={null}
// Key belongs to Company A
Authorization: Bearer sk_abc123def456ghi789
// ✅ Can access Company A's jobs
GET /jobs
// ❌ Cannot access Company B's jobs (even if you know the ID)
GET /jobs/company-b-job-uuid
// Returns: 403 Forbidden
```
### ATS Integration Keys
```javascript theme={null}
// ATS key with access to multiple companies
Authorization: Bearer sk_ats123xyz456abc789
// ✅ Must specify companyId for Company A
GET /jobs?companyId=company-a-uuid
// ✅ Can access Company B with different companyId
GET /jobs?companyId=company-b-uuid
// ❌ Cannot access Company C (not managed by this ATS key)
GET /jobs?companyId=company-c-uuid
// Returns: 403 Forbidden - "Access denied to this company"
```
## Scope Errors
The API key lacks the required scope
```json theme={null}
{
"message": "Insufficient permissions: Required scope write:jobs not found",
"error": "Forbidden",
"statusCode": 403
}
```
**Solution**: Add the required scope to your API key or create a new key with appropriate scopes.
Resource doesn't exist or belongs to another company
```json theme={null}
{
"message": "Access denied to this resource",
"error": "Forbidden",
"statusCode": 403
}
```
**Causes**:
* Resource belongs to a different company
* Resource has been soft-deleted
* Invalid resource ID
## Best Practices
Instead of one key with all scopes, create multiple keys for different purposes:
```javascript theme={null}
// Separate keys for different integrations
const analyticsKey = process.env.INSTAVIEW_ANALYTICS_KEY; // read-only
const syncKey = process.env.INSTAVIEW_SYNC_KEY; // write contacts
const automationKey = process.env.INSTAVIEW_AUTOMATION_KEY; // write conversations
```
Document which scopes each part of your application needs:
```javascript theme={null}
/**
* Contact Sync Service
*
* Required Scopes:
* - read:jobs (validate job ownership)
* - read:contacts (check for duplicates)
* - write:contacts (create/update contacts)
*/
class ContactSyncService {
// ...
}
```
Gracefully handle insufficient permission errors:
```javascript theme={null}
async function createJob(jobData) {
try {
return await api.post('/jobs', jobData);
} catch (error) {
if (error.response?.status === 403) {
console.error(
`Permission denied: ${error.response.data.message}`
);
// Notify admin or fallback to alternative workflow
}
throw error;
}
}
```
Periodically review API key scopes:
* Are all scopes still necessary?
* Can any keys be downgraded to read-only?
* Are there unused keys that can be revoked?
## Testing Scopes
### Verify Your Scopes
```javascript theme={null}
// Verify your API key by making a simple GET request
// to any read endpoint you have access to, such as:
GET /jobs
Authorization: Bearer sk_abc123def456ghi789
// If successful, your API key is valid and has the required scope
```
### Test Scope Requirements
```javascript theme={null}
// Test each operation with your key
const tests = [
{ method: "GET", path: "/jobs", scope: "read:jobs" },
{ method: "POST", path: "/jobs", scope: "write:jobs" },
{ method: "GET", path: "/contacts", scope: "read:contacts" },
{ method: "POST", path: "/contacts", scope: "write:contacts" },
];
for (const test of tests) {
try {
await api.request(test.method, test.path);
console.log(`✅ ${test.scope}: Success`);
} catch (error) {
if (error.response?.status === 403) {
console.log(`❌ ${test.scope}: Missing`);
}
}
}
```
## Next Steps
Learn about API key management
Understand rate limits per scope
Handle permission errors gracefully
Production integration patterns
# Webhooks
Source: https://docs.instaview.sk/guides/webhooks
Real-time notifications for events in your InstaView account
## Overview
Webhooks let your application be told when something happens in InstaView rather than polling for it. When a conversation completes, an analysis finishes, or another tracked event occurs, we POST it to your endpoint.
## How Webhooks Work
Register your webhook URL and specify which events to receive
A conversation completes, an analysis finishes, or another tracked event happens
InstaView sends an HTTP POST request to your endpoint with event details
Your server processes the event and returns a 2xx status code
## Event Types
InstaView supports the following webhook event types:
| Event | Legacy name | Description |
| - | - | - |
| `conversation.started` | `interview.started` | Conversation session began |
| `conversation.completed` | `interview.completed` | Conversation finished with analysis available |
| `conversation.failed` | `interview.failed` | Technical failure occurred during the conversation |
| `conversation.rescheduled` | `interview.rescheduled` | A call attempt did not connect (or the contact asked to move it) and a follow-up attempt has been scheduled |
| `conversation.cancelled` | `interview.cancelled` | Conversation reached a terminal state without completing |
Which of the two names **your** endpoint receives is set per webhook — see
[Event name vocabulary](#event-name-vocabulary). The other event families below are
unaffected.
| Event | Description |
| - | - |
| `analysis.completed` | Analysis completed for a call attempt |
| `analysis.failed` | Analysis failed for a call attempt |
| Event | Description |
| - | - |
| `sourcing.completed` | Sourcing agent run finished with a scored candidate list |
| `sourcing.failed` | Sourcing agent run terminated with an unrecoverable error |
| Event | Description |
| - | - |
| `enrich.completed` | Enrichment run completed (partial profile-level failures do not prevent this event) |
| `enrich.failed` | Enrichment run terminated with an unrecoverable error before any profile was processed |
| Event | Description |
| - | - |
| `composer.completed` | An agent-composer turn produced a preview, carried in the payload as `agent` |
| `composer.failed` | An agent-composer turn ended without producing a new preview. Carries `error`, and no `agent` — poll the session for the preview an earlier turn left, if there was one |
| Event | Description |
| - | - |
| `ping` | Test event for verifying webhook connectivity |
**Composer events are opt-in per session.** Unlike the conversation stream, they are sent
only when you pass `webhookId` to
[`POST /agents/composer`](/api-reference/agents/compose-agent), and the webhook you name
must subscribe to at least one of the two. Subscribe to both if you want to hear about every
outcome: one is enough to be accepted, and you then hear nothing about the other. A
`composer.completed` carries the same agent
[polling](/api-reference/agents/get-composer-session) would have given you, so a consumer can
use either and never both.
**Conversation and analysis events cover every conversation in the company**, not
only the ones you created through this API. A conversation started
from the InstaView dashboard emits the same conversation and `analysis.*`
stream as one you created yourself — under whichever event names your webhook's
[vocabulary](#event-name-vocabulary) selects. Filter on the `conversationId` values you know
about if you only want your own.
**For an ATS integration, a webhook registered on the parent company also
receives events for its child companies.** Register one endpoint on the parent
to cover every tenant, or one per child for separate endpoints — a child's
webhook only ever receives that child's events, and never a sibling's.
If you register on **both** a parent and one of its children, that child's
events reach both endpoints as two separate deliveries with different
`deliveryId`s. See [Handling duplicates](#handling-duplicates) for the key that
collapses them — `deliveryId` will not, by design.
### Event name vocabulary
Conversation events answer to two names. InstaView's API began in hiring, so its events were
named for it; agents now run sales, support and operations calls, where `interview.completed`
describes the wrong thing. Rather than rename the events and break every consumer switching on
the string, **each webhook carries the vocabulary its own events are delivered in**.
| `vocabulary` | You receive |
| - | - |
| `NEUTRAL` | `conversation.completed` — the default for any webhook created from now on |
| `LEGACY` | `interview.completed` — what every webhook created before this option receives |
**Nothing you have registered changes.** Every webhook that existed before this option was
set to `LEGACY`, and stays there until you change it. There is no deadline and no migration
to run. Only conversation events differ at all — `analysis.*`, `sourcing.*`, `enrich.*` and
`ping` are identical in both vocabularies.
**You receive exactly one of the two names, 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.
Switch when you are ready, with a `PATCH` on the webhook:
```json theme={null}
{ "vocabulary": "NEUTRAL" }
```
The switch applies to events **triggered after it**. 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. Switch back at any time; the same rule applies.
**Registration accepts either spelling too.** `"CONVERSATION_COMPLETED"` and
`"INTERVIEW_COMPLETED"` register the same event and are stored identically, so you never have
to write a noun you are migrating away from. What you *receive* is decided by `vocabulary`,
not by the spelling you registered with — the two settings are independent.
### Event Type Strings
There is a distinction between the strings used to **register** a webhook and the strings sent in the **webhook payload**:
#### Registration (Screaming Snake Case)
When creating or updating webhooks, use these uppercase strings in the `events` array. The five
conversation events accept either spelling and store identically, so use whichever you prefer.
Registering under a given spelling does not change what you receive: the delivered event name is
decided by `vocabulary` alone. Registering the same event under both spellings subscribes you
once, not twice.
| Register with | Or its legacy spelling |
| - | - |
| `"CONVERSATION_COMPLETED"` | `"INTERVIEW_COMPLETED"` |
| `"CONVERSATION_FAILED"` | `"INTERVIEW_FAILED"` |
| `"CONVERSATION_STARTED"` | `"INTERVIEW_STARTED"` |
| `"CONVERSATION_RESCHEDULED"` | `"INTERVIEW_RESCHEDULED"` |
| `"CONVERSATION_CANCELLED"` | `"INTERVIEW_CANCELLED"` |
The rest have one spelling each, because those nouns never changed:
* `"ANALYSIS_COMPLETED"` - Analysis finished successfully
* `"ANALYSIS_FAILED"` - Analysis processing failed
* `"PING"` - Test event for connectivity
* `"SOURCING_COMPLETED"` - Sourcing run finished with scored candidates
* `"SOURCING_FAILED"` - Sourcing run terminated with an error
* `"ENRICH_COMPLETED"` - Enrichment run completed
* `"ENRICH_FAILED"` - Enrichment run terminated with an error
Registering an event under both of its spellings subscribes you once, not twice — they are one
event, so you receive one delivery for it.
#### Payload (Dot Notation)
The `event` field in the JSON payload you receive will use lowercase dot notation (e.g.,
`"analysis.completed"`). For conversation events, which of the two dot-notation names you get
depends on the webhook's [vocabulary](#event-name-vocabulary) — `"conversation.completed"` by
default, `"interview.completed"` on a webhook registered before that option existed.
## Payload Structure
Conversation events below are shown in the **default `NEUTRAL` vocabulary**. A webhook on
`LEGACY` receives the same payloads under `interview.started`, `interview.completed`,
`interview.failed`, `interview.rescheduled` and `interview.cancelled` instead. Only the `event`
name differs — the payload body is identical in both. `analysis.*`, `sourcing.*`, `enrich.*` and
`ping` are the same either way.
All webhook payloads follow this structure:
```json theme={null}
{
"event": "conversation.completed",
"timestamp": "2024-01-20T14:30:00.000Z",
"deliveryId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
// Event-specific data
}
}
```
### Conversation Events Payload
```json theme={null}
{
"event": "conversation.completed",
"timestamp": "2024-01-20T14:30:00.000Z",
"deliveryId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"conversationId": "conversation-uuid",
"interviewId": "conversation-uuid",
"callAttemptId": "call-attempt-uuid",
"contactId": "contact-uuid",
"candidateId": "contact-uuid",
"runId": null,
"jobId": "job-uuid",
"status": "COMPLETED"
}
}
```
**The id fields are sent under both names, always identical.** `conversationId` and
`interviewId` carry the same value, as do `contactId` and `candidateId`, so you can move
field by field at your own pace rather than in one cut-over. `conversationId` and `contactId`
are the current names; the other two are kept for the life of `v1`.
Unlike the `event` label, **the payload body does not depend on the webhook's
[vocabulary](#event-name-vocabulary)** — both spellings are always present, on every
webhook. There is only ever one payload shape to handle.
**`runId` attributes the call to its batch.** It is `null` for a conversation created on
its own, and carries the [run's](/guides/resources/runs) id for one a run produced — a run
mints one conversation per contact. It is always present, so you can switch on it directly
without reading the conversation back.
### Conversation Rescheduled Payload
Sent when a call attempt did not produce a completed conversation and a follow-up attempt has been scheduled. This event sits **between** two call attempts, so unlike the other conversation events it does not carry a single `callAttemptId` — the two attempts are split explicitly.
The contact's line was busy, so the system scheduled another attempt on its own.
```json theme={null}
{
"event": "conversation.rescheduled",
"timestamp": "2024-01-20T14:30:00.000Z",
"deliveryId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"conversationId": "conversation-uuid",
"interviewId": "conversation-uuid",
"contactId": "contact-uuid",
"candidateId": "contact-uuid",
"runId": null,
"jobId": "job-uuid",
"status": "RESCHEDULED",
"reason": "BUSY",
"previousAttempt": {
"callAttemptId": "call-attempt-uuid-1",
"status": "BUSY",
"endedReason": "customer-busy",
"durationSeconds": 0,
"attemptNumber": 1
},
"nextAttempt": {
"callAttemptId": "call-attempt-uuid-2",
"scheduledAt": "2024-01-20T14:35:00.000Z",
"attemptNumber": 2
},
"retryCount": 1,
"maxAttempts": 5
}
}
```
The contact answered and asked to be called back at a specific time. The `reason` on the
wire is `CANDIDATE_REQUEST`.
```json theme={null}
{
"event": "conversation.rescheduled",
"timestamp": "2024-01-20T14:30:00.000Z",
"deliveryId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"conversationId": "conversation-uuid",
"interviewId": "conversation-uuid",
"contactId": "contact-uuid",
"candidateId": "contact-uuid",
"runId": null,
"jobId": "job-uuid",
"status": "RESCHEDULED",
"reason": "CANDIDATE_REQUEST",
"previousAttempt": {
"callAttemptId": "call-attempt-uuid-1",
"status": "COMPLETED",
"attemptNumber": 1
},
"nextAttempt": {
"callAttemptId": "call-attempt-uuid-2",
"scheduledAt": "2024-01-21T09:00:00.000Z",
"attemptNumber": 2
},
"retryCount": 1,
"candidateRequest": {
"rawText": "Could you call me back tomorrow morning?",
"timeResolved": true
}
}
}
```
**Field notes:**
| Field | Notes |
| - | - |
| `reason` | One of `CANDIDATE_REQUEST`, `NO_ANSWER`, `BUSY`, `VOICEMAIL`, `TECHNICAL_FAILURE`, `API_REQUEST`, `ADMIN`. Branch on this — it is a stable vocabulary. |
| `API_REQUEST` vs `ADMIN` | `API_REQUEST` means **you** moved the attempt via `PATCH /conversations/{id}/call-attempts/{attemptId}`. `ADMIN` means InstaView staff moved it from the internal admin panel — worth surfacing differently, since it indicates someone intervened on your behalf. |
| `previousAttempt.endedReason` | The raw underlying telephony reason. Diagnostic only; it is not a stable contract and may be absent. |
| `maxAttempts` | Present only on automatic retries, where a retry ceiling governs. A reschedule the contact asked for does not spend the retry budget, so the field is omitted. |
| `candidateRequest` | Present only when `reason` is `CANDIDATE_REQUEST`. |
| `candidateRequest.timeResolved` | `false` means the time the contact asked for could not be interpreted and a default slot was used instead — so they are **not** expecting a call at `scheduledAt`. Worth surfacing to whoever follows up. |
### Conversation Cancelled Payload
Sent when a conversation reaches a terminal state without completing — either cancelled explicitly, or abandoned after exhausting its retry budget.
```json theme={null}
{
"event": "conversation.cancelled",
"timestamp": "2024-01-20T14:30:00.000Z",
"deliveryId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"conversationId": "conversation-uuid",
"interviewId": "conversation-uuid",
"contactId": "contact-uuid",
"candidateId": "contact-uuid",
"runId": null,
"jobId": "job-uuid",
"status": "CANCELLED",
"reason": "MAX_ATTEMPTS_EXCEEDED",
"reasonDetail": "Retry limit of 5 reached (last failure: customer-busy)",
"lastCallAttemptId": "call-attempt-uuid-5",
"attemptsMade": 5,
"cancelledBy": "SYSTEM"
}
}
```
`reason` is one of:
| Reason | Meaning |
| - | - |
| `MAX_ATTEMPTS_EXCEEDED` | Every permitted call attempt was made without reaching the contact. |
| `CANDIDATE_NOT_INTERESTED` | The contact declined during the call. `reasonDetail` carries the finer-grained outcome (e.g. `hard_no`, `polite_no`). |
| `USER_REQUEST` | Cancelled by a user from the InstaView dashboard. `cancelledBy` is their user ID. |
| `ADMIN` | Cancelled by InstaView staff from the internal admin panel, with the reason in `reasonDetail`. |
`lastCallAttemptId` is absent when the conversation was cancelled before any call was placed.
**`data.status` is the event's status, not the conversation's.** This payload always
carries `status: "CANCELLED"`, but for `reason: "MAX_ATTEMPTS_EXCEEDED"` the
conversation itself now reads `UNREACHABLE` — fetching `GET /conversations/{id}`
after this webhook returns a different value than the payload shows. Branch on
`reason`, not on `data.status`, and re-read the resource if you mirror conversation
status into your own system.
There is no `API_REQUEST` cancellation reason. The public API has no
conversation-level cancel endpoint — `DELETE /conversations/{id}` removes the
conversation outright and cancels its pending webhook deliveries, so no
`conversation.cancelled` event is emitted for it. If you need a record of the
cancellation, capture it before issuing the delete.
### Rescheduling a call attempt yourself
`PATCH /conversations/{id}/call-attempts/{attemptId}` emits `conversation.rescheduled` with `reason: "API_REQUEST"`. Because this endpoint **moves an existing attempt** rather than ending one and creating another, the same `callAttemptId` appears in both `previousAttempt` and `nextAttempt`, and `previousAttempt.status` is `SCHEDULED` — no call took place. This is the only reason value where that holds; for every other reason the two IDs differ.
### Conversation Event Ordering
A conversation emits `conversation.started` once **per call attempt**, so multiple starts are normal. Exactly one terminal event is emitted per conversation — either `conversation.completed`, `conversation.cancelled`, or `conversation.failed`.
Two representative sequences:
```
# Busy on the first try, answered on the second
conversation.started → conversation.rescheduled (BUSY) → conversation.started
→ conversation.completed → analysis.completed
# Never reached
conversation.started → conversation.rescheduled (NO_ANSWER) → conversation.started
→ conversation.rescheduled (NO_ANSWER) → … → conversation.cancelled (MAX_ATTEMPTS_EXCEEDED)
```
Before these events existed, retries and cancellations were silent — an
integrator saw repeated start events with no explanation for the
gaps, and a conversation that exhausted its attempts never produced a terminal
event at all. If your handler infers state from event ordering, subscribe to
both new events.
### Analysis Events Payload
`analysis.general.overallRating` is `null` when the model that scores the conversation could not
be reached, and `scoringIncompleteReason` says why. A null rating means no score was produced, not
a bad one, so do not coerce it to `0`. On a `model_unavailable` run the analysis is re-run
automatically and `analysis.completed` fires again once the score exists, which means you can
receive this event more than once for the same conversation. See [Analysis
Structure](/guides/resources/conversations#when-there-is-no-rating).
```json theme={null}
{
"event": "analysis.completed",
"timestamp": "2024-01-20T14:30:00.000Z",
"deliveryId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"conversationId": "conversation-uuid",
"interviewId": "conversation-uuid",
"callAttemptId": "call-attempt-uuid",
"contactId": "contact-uuid",
"candidateId": "contact-uuid",
"runId": null,
"jobId": "job-uuid",
"status": "COMPLETED",
"analysis": {
"id": "analysis-uuid",
"general": {
"overallRating": 85,
"companyFitRating": "HIGH",
"education": "Master's degree in Computer Science",
"experience": "5 years of experience in software development",
"strongPoints": [
"Excellent communication skills",
"Strong technical expertise in React and Node.js",
"Proven track record of leading teams"
],
"weakPoints": [
"Limited experience with microservices",
"Needs improvement in system design"
],
"evaluation": [
"Strong candidate with relevant experience",
"Good cultural fit for the team",
"Recommended for next round"
],
"status": "COMPLETED",
"createdAt": "2024-01-20T14:30:00.000Z",
"updatedAt": "2024-01-20T14:30:00.000Z"
},
"specific": {}
},
"analysisPdfBase64": "JVBERi0xLjQKMSAwIG9iai...",
"callAttempts": [
{
"id": "call-attempt-uuid",
"direction": "OUTBOUND",
"status": "COMPLETED",
"recordingUrl": "https://api.instaview.sk/public/recordings/uuid.mp3",
"duration": 120,
"calledAt": "2024-01-20T14:30:00.000Z"
}
]
}
}
```
```json theme={null}
{
"event": "analysis.completed",
"timestamp": "2024-01-20T14:30:00.000Z",
"deliveryId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"conversationId": "conversation-uuid",
"interviewId": "conversation-uuid",
"callAttemptId": "call-attempt-uuid",
"contactId": "contact-uuid",
"candidateId": "contact-uuid",
"runId": null,
"jobId": "job-uuid",
"status": "COMPLETED",
"analysis": {
"id": "analysis-uuid",
"general": {
"overallRating": 95,
"status": "COMPLETED",
"createdAt": "2024-01-20T14:30:00.000Z",
"updatedAt": "2024-01-20T14:30:00.000Z"
},
"specific": {
"pronunciationScores": {
"accuracy": 92,
"fluency": 88,
"prosody": 90
},
"pronunciationAssessment": {
"estimated_pronunciation_level": "C1",
"reasoning": "Clear pronunciation with minor accent."
},
"grammar": {
"level": "C1",
"meets_tested_level": true,
"summary": "Strong command of grammar structures."
},
"vocabulary": {
"level": "C1",
"meets_tested_level": true,
"summary": "Wide vocabulary range used appropriately."
},
"overallScore": {
"level": "C1",
"confidence": 0.95,
"summary": "Excellent overall language proficiency."
},
"combinedCefrEvaluation": {
"final_level": "C1",
"confidence": 0.95,
"summary": "Candidate demonstrates C1 level proficiency."
}
}
},
"analysisPdfBase64": "JVBERi0xLjQKMSAwIG9iai...",
"callAttempts": [
{
"id": "call-attempt-uuid",
"direction": "OUTBOUND",
"status": "COMPLETED",
"recordingUrl": "https://api.instaview.sk/public/recordings/uuid.mp3",
"duration": 150,
"calledAt": "2024-01-20T14:30:00.000Z"
}
]
}
}
```
```json theme={null}
{
"event": "analysis.completed",
"timestamp": "2024-01-20T14:30:00.000Z",
"deliveryId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"conversationId": "conversation-uuid",
"interviewId": "conversation-uuid",
"callAttemptId": "call-attempt-uuid",
"contactId": "contact-uuid",
"candidateId": "contact-uuid",
"runId": null,
"jobId": "job-uuid",
"status": "COMPLETED",
"analysis": {
"id": "analysis-uuid",
"general": {
"status": "COMPLETED",
"createdAt": "2024-01-20T14:30:00.000Z",
"updatedAt": "2024-01-20T14:30:00.000Z"
},
"specific": {
"interestType": "interested_now",
"specificFeedback": "Candidate is very interested in the position and available immediately.",
"preferredNextStep": "technical_interview"
}
},
"analysisPdfBase64": "JVBERi0xLjQKMSAwIG9iai...",
"callAttempts": [
{
"id": "call-attempt-uuid",
"direction": "OUTBOUND",
"status": "COMPLETED",
"recordingUrl": "https://api.instaview.sk/public/recordings/uuid.mp3",
"duration": 45,
"calledAt": "2024-01-20T14:30:00.000Z"
}
]
}
}
```
A custom agent (one created with a conversation `flow`) fires the same `analysis.completed` event, and carries its own `analytics` alongside `analysis` — whatever its `analyticsConfig` asked for. `analysis` is the recruiting pipeline's output and is typically absent here; there is no `analysisPdfBase64` either, since the PDF report is a recruiting artefact.
```json theme={null}
{
"event": "analysis.completed",
"timestamp": "2026-08-03T14:30:00.000Z",
"deliveryId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"conversationId": "conversation-uuid",
"interviewId": "conversation-uuid",
"callAttemptId": "call-attempt-uuid",
"contactId": "contact-uuid",
"candidateId": "contact-uuid",
"runId": null,
"status": "COMPLETED",
"analytics": {
"matchScore": 72,
"fields": [
{
"key": "budget_confirmed",
"label": "Budget confirmed",
"type": "bool",
"value": true,
"confidence": 0.9,
"evidence": "Said they have signed off on 15k for this quarter"
},
{
"key": "timeline",
"label": "Timeline",
"type": "string",
"value": null
}
],
"scoring": {
"total": 72,
"items": [
{
"key": "budget_confirmed",
"kind": "field",
"label": "Budget confirmed",
"score": 90,
"weightPercent": 50,
"importance": "REQUIRED"
},
{
"key": "3f1a…-question-uuid",
"kind": "question",
"label": "What are you using today?",
"score": 60,
"weightPercent": 30
},
{
"key": "handled_objections",
"kind": "criterion",
"label": "Handled objections",
"score": 50,
"weightPercent": 20
}
]
},
"outcomes": [
{ "key": "meeting_booked", "label": "Meeting booked", "met": true, "confidence": 0.8 }
],
"tags": [
{ "key": "needs_followup", "label": "Needs follow-up", "applied": false }
],
"qa": [
{
"id": "3f1a…-question-uuid",
"question": "What are you using today?",
"answered": true,
"answer": "Spreadsheets, rebuilt by hand every week",
"evidence": "It's the worst two hours of my Monday"
}
],
"evaluation": {
"strengths": ["Clear pain point, already shopping for a replacement"],
"concerns": ["Finance sign-off not secured"],
"objections": ["Worried about migrating two years of historical data"],
"assessment": ["Strong fit; the only real risk is procurement timing"]
},
"sentiment": { "overall": "positive", "score": 0.8, "trajectory": "improving" },
"summary": "Qualified lead; demo booked for Thursday.",
"updatedAt": "2026-08-03T14:30:00.000Z"
},
"callAttempts": [
{
"id": "call-attempt-uuid",
"direction": "OUTBOUND",
"status": "COMPLETED",
"recordingUrl": "https://api.instaview.sk/public/recordings/uuid.mp3",
"duration": 96,
"calledAt": "2026-08-03T14:28:00.000Z"
}
]
}
}
```
`analytics` is **additive** — it appears only for custom agents, so an existing
integration sees no change. `value: null` on a field means the call did not
surface it, which is distinct from a `false` or empty value that was surfaced.
`weightPercent` is each item's share of the score, so the shares sum to 100.
In `qa`, `answered: false` means the call never reached the question rather than
that it was answered with nothing.
The same object is served on `GET /conversations/{id}` as `analytics`.
### Failed Event Payloads
Failed events include an error message:
```json theme={null}
{
"event": "conversation.failed",
"timestamp": "2024-01-20T14:30:00.000Z",
"deliveryId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"conversationId": "conversation-uuid",
"interviewId": "conversation-uuid",
"callAttemptId": "call-attempt-uuid",
"contactId": "contact-uuid",
"candidateId": "contact-uuid",
"runId": null,
"jobId": "job-uuid",
"status": "FAILED",
"errorMessage": "Connection timeout during call"
}
}
```
### Ping Event Payload
```json theme={null}
{
"event": "ping",
"timestamp": "2024-01-20T14:30:00.000Z",
"deliveryId": "550e8400-e29b-41d4-a716-446655440000",
"data": {
"message": "Test ping from InstaView webhook system"
}
}
```
Most webhook payloads contain minimal data (IDs and status), but the
`analysis.completed` event includes the full analysis results and the
`analysisPdfBase64` Base64-encoded PDF report (when generation succeeds). Its
`callAttempts` is a snapshot of every attempt on the conversation so far, each
with its own `transcript` when available, so a later event never drops what an earlier one
carried. For other details, use the [Conversation
API](/api-reference/conversations/get-conversation) to fetch the complete resource.
## Per-Company Webhooks
For ATS integrations managing multiple companies, you can register webhooks per company by specifying the `companyId` parameter when creating a webhook. This enables:
* **Company-specific endpoints**: Each company can have its own webhook URL (e.g., different subdomains)
* **Selective event delivery**: Webhooks with `companyId` only receive events for that specific company
* **Global webhooks**: Webhooks without `companyId` receive events for all companies accessible by the API key
### Example: Per-Company Setup
```javascript theme={null}
// Webhook for Company A
await createWebhook({
url: "https://company-a.yourats.com/webhooks/instaview",
companyId: "company-a-uuid",
events: ["CONVERSATION_COMPLETED", "ANALYSIS_COMPLETED"],
});
// Webhook for Company B
await createWebhook({
url: "https://company-b.yourats.com/webhooks/instaview",
companyId: "company-b-uuid",
events: ["CONVERSATION_COMPLETED", "ANALYSIS_COMPLETED"],
});
// Global webhook (receives all events)
await createWebhook({
url: "https://admin.yourats.com/webhooks/instaview",
// No companyId - receives events for all companies
events: ["CONVERSATION_COMPLETED", "ANALYSIS_COMPLETED"],
});
```
### Filtering by Company
When listing webhooks, you can filter by company:
```javascript theme={null}
// List all webhooks (includes both company-specific and global webhooks)
const allWebhooks = await fetch("/webhooks", { headers });
// List webhooks for a specific company (excludes global webhooks)
// Only returns webhooks that have the specified companyId
const companyWebhooks = await fetch("/webhooks?companyId=company-uuid", {
headers,
});
```
**Note**: When filtering by `companyId`, only company-specific webhooks are returned. Global webhooks (those without a `companyId`) are excluded from filtered results. To see all webhooks including global ones, omit the `companyId` parameter.
## Required Scopes
| Operation | Required Scope |
| - | - |
| List webhooks | `read:webhooks` |
| Get webhook by ID | `read:webhooks` |
| Create webhook | `write:webhooks` |
| Update webhook | `write:webhooks` |
| Delete webhook | `write:webhooks` |
| Test webhook | `write:webhooks` |
| Reset circuit breaker | `write:webhooks` |
## Security
### Signature Verification
Every webhook request includes an HMAC-SHA256 signature in the `X-Webhook-Signature` header. **Always verify this signature** to ensure requests are from InstaView.
The signature format is: `sha256=`
```javascript Node.js theme={null}
const crypto = require("crypto");
function verifyWebhookSignature(payload, signature, secret) {
if (typeof signature !== "string" || !signature.startsWith("sha256=")) {
return false;
}
const expectedSignature =
"sha256=" +
crypto
.createHmac("sha256", Buffer.from(secret, "hex"))
.update(payload, "utf8")
.digest("hex");
const received = Buffer.from(signature, "utf8");
const expected = Buffer.from(expectedSignature, "utf8");
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(received, expected);
}
// Express.js example
app.post(
"/webhooks/instaview",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["x-webhook-signature"];
const payload = req.body.toString();
if (
!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)
) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(payload);
// Process the event...
res.status(200).send("OK");
},
);
```
```python Python theme={null}
import hmac
import hashlib
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
expected = 'sha256=' + hmac.new(
bytes.fromhex(secret),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
# Flask example
@app.route('/webhooks/instaview', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Webhook-Signature')
payload = request.get_data()
if not verify_webhook_signature(payload, signature, os.environ['WEBHOOK_SECRET']):
return 'Invalid signature', 401
event = request.get_json()
# Process the event...
return 'OK', 200
```
```php PHP theme={null}
### Request Headers
Every webhook request includes these headers:
| Header | Description |
| - | - |
| `Content-Type` | Always `application/json` |
| `X-Webhook-Signature` | HMAC-SHA256 signature for payload verification |
| `X-Webhook-Delivery-Id` | Unique ID for this delivery (for idempotency) |
| `X-Webhook-Event` | Event type (e.g., `conversation.completed`) |
| `User-Agent` | `InstaView-Webhook/1.0` |
Plus any custom headers you configured when creating the webhook.
### Secret Management
**Store your signing secret securely!** The signing secret is only returned
once when you create a webhook configuration. If lost, you must delete and
recreate the webhook to get a new secret.
Best practices for secret management:
* Store secrets in environment variables or a secrets manager
* Never commit secrets to version control
* Rotate secrets periodically by creating new webhooks
* Use different secrets for development and production
## Delivery Guarantees
**At least once, for as long as the retry budget lasts.** Every event InstaView
records is attempted against each subscribed endpoint, and may be attempted more than
once — so design your handler to tolerate a repeat; see
[Handling duplicates](#handling-duplicates) for the keys that collapse them. It is not
an unconditional promise of arrival: an endpoint that keeps failing exhausts the retry
budget described in [Retry Logic](#retry-logic), and the delivery is then marked failed
and abandoned. What InstaView guarantees is that no recorded event is ever silently
dropped on our side — every one is attempted, and a failure to deliver is a visible
failed delivery rather than an event that quietly never existed.
What "records" means matters, because it is what the guarantee rests on. The moment a
conversation is cancelled, an analysis finishes, or any other event occurs, InstaView
writes that fact to durable storage **in the same database transaction as the change
itself**. Building the payload, resolving which endpoints are subscribed, and queueing
the request all happen afterwards, and all of those steps are retried until they
succeed — they are internal, so nothing about your endpoint can exhaust them. Only the
HTTP delivery itself has a finite budget. So:
* If the underlying operation is rolled back, no event is sent. There is no window in
which you are told a conversation was cancelled when it was not.
* If the operation commits, the event is owed to you even if InstaView restarts, loses
a database connection, or fails to read the conversation in the instant afterwards.
Nothing between the commit and the request can silently discard it.
**Ordering is not guaranteed.** Events are delivered independently and retried
independently, so a retried `conversation.started` can arrive after the
`conversation.completed` that followed it. Use the timestamps and identifiers in the
payload rather than arrival order.
**A delivery is in flight only once.** Each delivery attempt is claimed exclusively
before the request goes out, so overlapping internal retry passes cannot send the same
delivery twice in parallel. Duplicates you do see are genuine retries after a failure
or timeout, and carry the `deliveryId` that lets you recognise them.
**Latency.** Ordinary events are sent as soon as their transaction commits, typically
within a second. Bulk events — the `conversation.cancelled` fan-out from a cancelled flow
run, which can be thousands of conversations — are queued and drained in batches, so
expect those to arrive over the following minutes rather than instantly.
## Retry Logic
If your endpoint fails to respond with a 2xx status code, InstaView automatically retries delivery with exponential backoff:
First delivery attempt immediately when event occurs
After a short delay if first attempt fails
After a longer delay if second attempt fails
Final attempt with maximum delay
After all retries are exhausted, the delivery is marked as failed. Each attempt reuses
the same `deliveryId`, so a retry is always recognisable as a repeat of the attempt
before it.
### Circuit Breaker
To protect both your system and ours, InstaView implements a circuit breaker pattern:
* If a webhook consistently fails, it will be automatically disabled
* You can monitor the `consecutiveFailures` count in the webhook configuration
* When disabled, the `circuitOpenedAt` timestamp indicates when the circuit opened
* Use the **Reset Circuit Breaker** endpoint to re-enable the webhook after fixing issues
### Handling Duplicates
There are two different kinds of duplicate, and they need different keys.
**The same delivery arriving twice** — a retry after your endpoint timed out, or after
it answered non-2xx. Both copies carry the same `deliveryId`. Track the `deliveryId`
values you have processed and ignore a repeat, as in [Be Idempotent](#endpoint-requirements)
below.
**The same event delivered to two of your endpoints** — you registered a webhook on an
ATS parent company *and* on one of its children, so an event in the child matches both
configurations. These are genuinely separate deliveries with different `deliveryId`s,
and `deliveryId` will not collapse them. Match instead on the event name plus the
identifier of the occurrence the payload describes:
| Event | Occurrence key |
| - | - |
| `conversation.started`, `conversation.completed`, `conversation.failed` | `data.callAttemptId` |
| `analysis.completed`, `analysis.failed` | `data.callAttemptId` |
| `conversation.rescheduled` | `data.previousAttempt.callAttemptId` |
| `conversation.cancelled` | `data.conversationId` — fires at most once per conversation |
Do not key on `data.conversationId` alone for the per-attempt events. One conversation
emits `conversation.rescheduled` once per retry and `conversation.started` once per
attempt; treating those as duplicates of each other silently drops events you
wanted. Only `conversation.cancelled` is once-per-conversation.
If you register a single endpoint per company — the common case — neither situation
beyond ordinary retries arises, and `deliveryId` on its own is enough.
Both keys are stable across an InstaView restart or failover. Delivery is recovered
from durable storage rather than from memory, so a redeployment mid-flight resumes
the same delivery rather than starting a new one under a new `deliveryId`.
## Endpoint Requirements
Your webhook endpoint must:
Return a 2xx response within 30 seconds. For long-running tasks, acknowledge
receipt immediately and process asynchronously.
```javascript theme={null}
app.post('/webhooks/instaview', async (req, res) => {
// Acknowledge immediately
res.status(200).send('OK');
// Process asynchronously
processWebhookAsync(req.body).catch(console.error);
});
```
Handle duplicate deliveries gracefully using the `deliveryId`:
```javascript theme={null}
// Note: Use Redis or database storage in production for multi-instance deployments
const processedDeliveries = new Set();
app.post('/webhooks/instaview', (req, res) => {
const { deliveryId } = req.body;
if (processedDeliveries.has(deliveryId)) {
return res.status(200).send('Already processed');
}
processedDeliveries.add(deliveryId);
// Process the event...
res.status(200).send('OK');
});
```
Production webhook URLs must use HTTPS. HTTP is only allowed for localhost
during development.
Any 2xx status code indicates successful receipt. Retry behavior depends on the status code:
| Status | Meaning |
| - | - |
| 200, 201, 202 | Success - no retry |
| 429 | Rate limit - will retry |
| 4xx (except 429) | Client error - no retry (permanent errors like 401, 404) |
| 5xx | Server error - will retry |
## Managing Webhooks
### Create a Webhook
```javascript theme={null}
const response = await fetch("https://api.instaview.sk/webhooks", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://api.yourcompany.com/webhooks/instaview",
name: "Production Webhook",
description: "Receives conversation completion notifications",
events: ["CONVERSATION_COMPLETED", "ANALYSIS_COMPLETED"],
companyId: "company-uuid", // Optional: Associate with specific company
headers: [{ name: "Authorization", value: "Bearer your-internal-token" }],
}),
});
const data = await response.json();
// IMPORTANT: Store this secret securely - it's only shown once!
console.log("Signing secret:", data.signingSecret);
```
**Per-Company Webhooks**: For ATS integrations managing multiple companies,
you can specify `companyId` when creating a webhook. This allows you to
register separate webhooks per company (e.g., different subdomains). If
omitted, the webhook will receive events for all companies accessible by the
API key (global webhook).
### List Webhooks
```javascript theme={null}
// List all webhooks (includes both company-specific and global webhooks)
const response = await fetch("https://api.instaview.sk/webhooks", {
headers: { Authorization: `Bearer ${apiKey}` },
});
// Filter by company ID (returns only company-specific webhooks, excludes global ones)
const responseFiltered = await fetch(
"https://api.instaview.sk/webhooks?companyId=company-uuid",
{
headers: { Authorization: `Bearer ${apiKey}` },
},
);
const data = await response.json();
console.log(`Found ${data.total} webhooks`);
for (const webhook of data.data) {
console.log(`${webhook.name}: ${webhook.url}`);
console.log(` Company: ${webhook.companyId || "All companies"}`);
console.log(` Events: ${webhook.events.join(", ")}`); // These will be labels like "analysis.completed"
console.log(` Active: ${webhook.isActive}`);
console.log(` Failures: ${webhook.consecutiveFailures}`);
}
```
### Update a Webhook
```javascript theme={null}
await fetch(`https://api.instaview.sk/webhooks/${webhookId}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://api.yourcompany.com/webhooks/instaview-v2",
events: [
"CONVERSATION_COMPLETED",
"CONVERSATION_FAILED",
"ANALYSIS_COMPLETED",
"ANALYSIS_FAILED",
],
isActive: true,
}),
});
```
### Delete a Webhook
```javascript theme={null}
await fetch(`https://api.instaview.sk/webhooks/${webhookId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${apiKey}` },
});
```
### Test a Webhook
Send a test ping to verify connectivity:
```javascript theme={null}
const response = await fetch(
`https://api.instaview.sk/webhooks/${webhookId}/test`,
{
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
const data = await response.json();
if (data.success) {
console.log("Webhook test successful!");
console.log(`Response time: ${data.durationMs}ms`);
} else {
console.error("Webhook test failed:", data.message);
}
```
To test a webhook using the test endpoint above, it must be subscribed to the
`ping` event.
### Testing Conversation Events with Test Conversations
To exercise the conversation events end to end (`conversation.completed`, `analysis.completed`), create a conversation with the `isTest` flag — it completes immediately and consumes no minutes:
```javascript theme={null}
// Create a test conversation that completes immediately
const response = await fetch("https://api.instaview.sk/conversations", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
contactId: "contact-uuid",
agentId: "agent-uuid",
isTest: true, // Completes immediately, bills nothing
}),
// Without a deadline this request never settles if the API stops answering.
signal: AbortSignal.timeout(10_000),
});
// Nothing was created unless the API said so — a 4xx body parses just as happily as a
// conversation would, and waiting for webhooks that will never arrive is the failure mode.
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(
`Creating the test conversation failed: ${response.status} ${error.message ?? response.statusText}`,
);
}
const testConversation = await response.json();
// Now that it exists, it will trigger:
// 1. conversation.completed webhook
// 2. analysis.completed webhook
// Both events will be sent to your webhook endpoint
```
Useful for:
* Exercising your handler's completion path
* Checking that analysis data is parsed and stored correctly
* End-to-end testing without waiting for a real call
* Development and debugging without consuming billing minutes
See [Create Conversation](/api-reference/conversations/create-conversation) for the full detail on test mode.
### Reset Circuit Breaker
If your webhook was disabled due to consecutive failures:
```javascript theme={null}
await fetch(`https://api.instaview.sk/webhooks/${webhookId}/reset-circuit`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` },
});
```
## Custom Headers
You can configure custom headers to authenticate with your endpoint:
```javascript theme={null}
{
"url": "https://api.yourcompany.com/webhooks/instaview",
"headers": [
{ "name": "Authorization", "value": "Bearer your-internal-token" },
{ "name": "X-Custom-Header", "value": "custom-value" }
],
"events": ["CONVERSATION_COMPLETED", "ANALYSIS_COMPLETED"]
}
```
**Security**: Sensitive headers (Authorization, X-API-Key, X-Secret, API-Key)
are automatically encrypted at rest. When listing webhooks, these header
values are masked for security.
## Best Practices
Always verify the `X-Webhook-Signature` header to ensure requests are
authentic
Return 200 immediately and process asynchronously for long-running tasks
Use `deliveryId` for idempotency - you may receive the same event multiple
times
Check `consecutiveFailures` and `circuitOpenedAt` to catch issues early
## Troubleshooting
**Possible causes:** - Webhook is not subscribed to the event type - Webhook
is disabled (`isActive: false`) - Circuit breaker is open (check
`circuitOpenedAt`) **Solutions:** - Verify event subscriptions in webhook
configuration - Check if webhook is active - Reset circuit breaker if needed
**Possible causes:** - Using wrong signing secret - Modifying payload before
verification - Encoding issues **Solutions:** - Verify you're using the
original signing secret - Verify the raw request body, not parsed JSON -
Ensure UTF-8 encoding
**Cause:** Too many consecutive failures triggered the circuit breaker
**Solutions:** 1. Fix the underlying issue (endpoint availability,
authentication, etc.) 2. Use the test endpoint to verify connectivity 3. Reset
the circuit breaker
**Possible causes:** - Event occurred before webhook was configured - Event
type not in subscription list **Note:** Webhooks only deliver events that
occur after configuration. For historical data, use the API to poll for past
events.
## Next Steps
Detailed webhook configuration reference
Complete webhook API documentation
Learn what a conversation carries
Handle webhook delivery errors
# Welcome to InstaView API
Source: https://docs.instaview.sk/introduction
Build AI-run phone and web conversations on the InstaView API
## What is InstaView?
InstaView runs conversations: an AI agent places a call, phone or web, has the conversation you designed with it, and hands back a transcript and structured results. It began in hiring, which is still where most of its volume is, and the same agents now run sales, support and operations calls. The API puts all of that behind a handful of resources you can drive from your own application, your ATS, or a workflow of your own.
Get up and running with your first API call in minutes
Learn how to authenticate with API keys and manage scopes
Explore all available endpoints and their parameters
Deep dive into concepts and best practices
## Key Features
Create, update and manage the people you call, with as much profile data as you want to keep. Associate them with jobs where that applies, and follow their conversations.
Define job postings with detailed requirements, skills, and descriptions.
Organize your recruitment efforts with flexible job status tracking and
company-level isolation.
Configure the agent that runs the call: its voice, the conversation flow it
follows, and what it should extract. Either from a hiring template or as a
custom flow of your own design.
Place a call now or at a time you name, with retries and calling hours
handled for you, and a status you can follow to a terminal value.
Read the full transcript, the hiring analysis where a hiring focus produced
one, and the structured fields, outcomes and scoring a custom agent was
configured to extract.
Company-level data isolation, scope-based access control, audit logging, and secure API key management ensure your data stays protected.
## Use Cases
### ATS Integration
Connect your Applicant Tracking System so screening calls happen without anyone scheduling them by hand. Built for Greenhouse, Lever and custom systems alike.
### Custom Recruitment Portals
Build a branded hiring experience with InstaView running the calls underneath. You keep full control of what the applicant sees.
### High-Volume Screening
Run first-round screening calls at volume — hundreds at once, each held and scored to the same standard.
### Outbound Sales and Support
Qualify inbound leads, chase renewals, run check-in calls. A custom agent runs the flow you wrote and returns the fields you asked it to extract, rather than a hiring assessment.
### Multi-Tenant Platforms
Build marketplaces or multi-company platforms on InstaView's company isolation and ATS keys.
## API Architecture
The InstaView API follows RESTful principles with a resource-oriented design:
* **Base URL**: `https://api.instaview.sk`
* **Authentication**: API Key via `Authorization: Bearer` header
* **Format**: JSON request and response bodies
* **Versioning**: URL-based versioning (currently v1)
* **Security**: TLS 1.2+, scope-based permissions, rate limiting
## Core Resources
Job postings and position management
The people you call, and what you know about them
One call each, with its transcript and results
Agent configuration, templates and custom flows
Company management and settings
Usage tracking and subscription management
## Getting Help
Ask our support team for help
Check API status and uptime
## Next Steps
[Generate your API key](/guides/api-keys) from the InstaView dashboard with
appropriate scopes
Follow our [quickstart guide](/quickstart) to make your first API call
Learn about [core resources](/guides/resources/jobs) and how they work
together
Apply [best practices](/guides/best-practices) to build a production-ready
integration
**API Version**: This documentation covers the latest version of the InstaView
API.
# Quickstart
Source: https://docs.instaview.sk/quickstart
Make your first API call to InstaView in under 5 minutes
## Get Your API Key
Before you can make API calls, you'll need an API key from your InstaView dashboard.
Log in to your [InstaView dashboard](https://app.instaview.sk)
Go to **Settings** → **API Keys**
Click **Create API Key** and select the scopes you need
Copy and securely store your API key - you won't be able to see it again!
**Keep your API key secure!** Never commit it to version control or expose it
in client-side code. Use environment variables or secure secret management.
## Make Your First Request
Let's create your first job posting using the InstaView API.
```bash cURL theme={null}
curl -X POST https://api.instaview.sk/jobs \
-H "Authorization: Bearer sk_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"title": "Senior Software Engineer",
"description": "We are looking for an experienced software engineer...",
"requiredSkills": ["JavaScript", "React", "Node.js"],
"location": {
"workMode": "HYBRID",
"city": "San Francisco",
"countryCode": "US"
},
"status": "OPEN"
}'
```
```javascript Node.js theme={null}
const axios = require("axios");
const apiKey = process.env.INSTAVIEW_API_KEY;
const baseURL = "https://api.instaview.sk";
async function createJob() {
try {
const response = await axios.post(
`${baseURL}/jobs`,
{
title: "Senior Software Engineer",
description: "We are looking for an experienced software engineer...",
requiredSkills: ["JavaScript", "React", "Node.js"],
location: {
workMode: "HYBRID",
city: "San Francisco",
countryCode: "US",
},
status: "OPEN",
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
},
);
console.log("Job created:", response.data);
return response.data;
} catch (error) {
console.error("Error:", error.response?.data || error.message);
}
}
createJob();
```
```python Python theme={null}
import os
import requests
api_key = os.environ.get('INSTAVIEW_API_KEY')
base_url = 'https://api.instaview.sk'
def create_job():
headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json'
}
job_data = {
'title': 'Senior Software Engineer',
'description': 'We are looking for an experienced software engineer...',
'requiredSkills': ['JavaScript', 'React', 'Node.js'],
'location': {
'workMode': 'HYBRID',
'city': 'San Francisco',
'countryCode': 'US'
},
'status': 'OPEN'
}
response = requests.post(
f'{base_url}/jobs',
headers=headers,
json=job_data
)
if response.ok:
job = response.json()
print(f"Job created: {job['id']}")
return job
else:
print(f"Error: {response.status_code}")
print(response.json())
create_job()
```
```php PHP theme={null}
'Senior Software Engineer',
'description' => 'We are looking for an experienced software engineer...',
'requiredSkills' => ['JavaScript', 'React', 'Node.js'],
'location' => [
'workMode' => 'HYBRID',
'city' => 'San Francisco',
'countryCode' => 'US'
],
'status' => 'OPEN'
];
$ch = curl_init($baseUrl . '/jobs');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($jobData));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json'
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 200 && $httpCode < 300) {
$result = json_decode($response, true);
echo "Job created: " . $result['id'] . "\n";
return $result;
} else {
echo "Error: $httpCode\n";
echo $response . "\n";
}
}
createJob($apiKey, $baseUrl);
?>
```
### Expected Response
```json theme={null}
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"companyId": "987e6543-e21b-12d3-a456-426614174000",
"title": "Senior Software Engineer",
"description": "We are looking for an experienced software engineer...",
"requiredSkills": ["JavaScript", "React", "Node.js"],
"niceToHaveSkills": [],
"location": {
"workMode": "HYBRID",
"city": "San Francisco",
"countryCode": "US"
},
"status": "OPEN",
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
```
## Complete Workflow Example
A complete workflow: create a job, add a contact, configure an agent, and have it place the call.
Use the example above to create a job posting
Add the person to call, and associate them with the job
Configure the agent that will run the call
Schedule the call
Fetch the transcript and analysis
```javascript Node.js theme={null}
const axios = require("axios");
const client = axios.create({
baseURL: "https://api.instaview.sk",
headers: {
Authorization: `Bearer ${process.env.INSTAVIEW_API_KEY}`,
"Content-Type": "application/json",
},
});
async function completeWorkflow() {
// 1. Create a job
const job = await client.post("/jobs", {
title: "Frontend Developer",
description: "React and TypeScript expert needed",
requiredSkills: ["React", "TypeScript"],
status: "OPEN",
});
const jobId = job.data.id;
console.log("Job created:", jobId);
// 2. Create a contact
const contact = await client.post("/contacts", {
jobId: jobId,
firstName: "Jane",
lastName: "Doe",
email: "jane.doe@example.com",
phoneNumber: "+1234567890",
gdprExpiryDate: new Date(
Date.now() + 365 * 24 * 60 * 60 * 1000,
).toISOString(),
});
const contactId = contact.data.id;
console.log("Contact created:", contactId);
// 3. Create the agent that runs the call
const agent = await client.post("/agents", {
name: "Technical Screening Agent",
voiceId: "ALEX",
type: "ONLINE",
focus: "SCREENING",
questions: [
"Tell me about your React experience",
"Explain the difference between props and state",
"How do you handle async operations in React?",
],
duration: 30,
});
const agentId = agent.data.id;
console.log("Agent created:", agentId);
// 4. Create the conversation
const conversation = await client.post("/conversations", {
contactId: contactId,
agentId: agentId,
scheduleTime: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // Tomorrow
});
const conversationId = conversation.data.id;
console.log("Conversation scheduled:", conversationId);
// 5. Later: read the results
// (once the call has completed)
const results = await client.get(`/conversations/${conversationId}`);
console.log("Conversation status:", results.data.status);
if (results.data.analysis) {
console.log("Overall rating:", results.data.analysis.general.overallRating);
}
}
completeWorkflow().catch(console.error);
```
```python Python theme={null}
import os
import requests
from datetime import datetime, timedelta
base_url = 'https://api.instaview.sk'
headers = {
'Authorization': f'Bearer {os.environ["INSTAVIEW_API_KEY"]}',
'Content-Type': 'application/json'
}
def complete_workflow():
# 1. Create a job
job_response = requests.post(
f'{base_url}/jobs',
headers=headers,
json={
'title': 'Frontend Developer',
'description': 'React and TypeScript expert needed',
'requiredSkills': ['React', 'TypeScript'],
'status': 'OPEN'
}
)
job_id = job_response.json()['id']
print(f'Job created: {job_id}')
# 2. Create a contact
contact_response = requests.post(
f'{base_url}/contacts',
headers=headers,
json={
'jobId': job_id,
'firstName': 'Jane',
'lastName': 'Doe',
'email': 'jane.doe@example.com',
'phoneNumber': '+1234567890',
'gdprExpiryDate': (datetime.now() + timedelta(days=365)).isoformat()
}
)
contact_id = contact_response.json()['id']
print(f'Contact created: {contact_id}')
# 3. Create the agent that runs the call
agent_response = requests.post(
f'{base_url}/agents',
headers=headers,
json={
'name': 'Technical Screening Agent',
'voiceId': 'ALEX',
'type': 'ONLINE',
'focus': 'SCREENING',
'questions': [
'Tell me about your React experience',
'Explain the difference between props and state',
'How do you handle async operations in React?'
],
'duration': 30
}
)
agent_id = agent_response.json()['id']
print(f'Agent created: {agent_id}')
# 4. Create the conversation
tomorrow = (datetime.now() + timedelta(days=1)).isoformat()
conversation_response = requests.post(
f'{base_url}/conversations',
headers=headers,
json={
'contactId': contact_id,
'agentId': agent_id,
'scheduleTime': tomorrow
}
)
conversation_id = conversation_response.json()['id']
print(f'Conversation scheduled: {conversation_id}')
# 5. Later: read the results
results = requests.get(
f'{base_url}/conversations/{conversation_id}',
headers=headers
)
conversation = results.json()
print(f'Conversation status: {conversation["status"]}')
if conversation.get('analysis'):
print(f'Overall rating: {conversation["analysis"]["general"]["overallRating"]}')
complete_workflow()
```
## Understanding the Response Format
**There is no envelope.** A successful response body *is* the resource, as in the example
above, and whether the call succeeded is carried by the HTTP status code rather than by a
field in the body.
A list endpoint returns a page object instead — the rows in `data`, the counts beside it:
```json theme={null}
{
"data": [ { "id": "…" } ],
"total": 25,
"page": 1,
"limit": 20,
"totalPages": 2
}
```
An error is a flat object:
```json theme={null}
{
"statusCode": 404,
"message": "Job not found",
"traceId": "4b1f0c9d2e6a47f8b3c5d7e9a1b2c3d4",
"timestamp": "2026-08-03T10:30:00.000Z"
}
```
Every response also carries an `x-trace-id` header holding that same trace id. Quote it when
you write to support.
Responses used to be wrapped in `{ statusCode, message, data, traceId }`, which put list rows
at `data.data`. An integration that predates the change keeps receiving the old shape until
**4 September 2026, 00:00 Europe/Prague**, and can tell because its responses carry a `Sunset`
header until then. See the [changelog](/changelog/2026-08). Anything you build today gets the
shape above.
See [Error Handling](/guides/error-handling) for the full list of status codes, and
[Pagination](/guides/pagination) for the page object.
## Next Steps
Learn about API keys, scopes, and security best practices
Explore contacts, conversations, agents, jobs and more
Handle errors gracefully in your integration
Browse complete API documentation
**Pro Tip**: Use our interactive API playground in the API Reference section
to test endpoints without writing code!