> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kalent.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Qualified search overview

> How a qualified search runs, what it costs, and how to follow it with polling or webhooks.

<Card title="First time? Set up Authentication" icon="key" href="/authentication">
  Learn how to authenticate your API requests with your API key.
</Card>

A **qualified search** does two things [`POST /v1/search/talents`](/api-reference/search-talents) does not: it evaluates every matching profile against your `qualificationCriterias`, and it keeps working on its own until it has found the number of green talents you asked for or spent the credits you allowed. You start it, then read the results as they arrive.

## When to use it

| You want to…                                                                                                                                | Use                                                                                                       |
| ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| Get a page of matching profiles right now, and judge them yourself                                                                          | [`POST /v1/search/talents`](/api-reference/search-talents) — one credit per profile returned, synchronous |
| Get profiles that already carry a green / amber / red verdict against your criteria, and let Kalent keep looking until it has enough greens | Qualified search — two credits per judged profile, asynchronous                                           |

Typical use cases:

* **Fill a shortlist in your ATS**: ask for `target.greens: 10`, subscribe a webhook to `green` only, and push each `talent_judged` event into the job's pipeline.
* **Budget-capped qualification per job**: set `target.maxCredits` per requisition; Kalent never spends more on that call, and you decide with a continue whether the next budget is worth it.
* **Sourcing from a job description**: send the description as a `prompt` to [`POST /v1/search/talents/qualified/by-prompt`](/api-reference/search-talents-qualified-by-prompt); Kalent turns it into filters and criteria, then qualifies.

## Search, call, wave

| Term       | Meaning                                                                                                                                                                           |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Search** | One `castingId`. Holds the filters, the criteria, the scoring language, the stored `target`, `webhook`, and `customParams`, and every verdict ever produced.                      |
| **Call**   | One start or one continue. Each call has its own quota: `target.greens` greens to find **during this call** and `target.maxCredits` credits it may spend.                         |
| **Wave**   | An internal round of analysis inside a call. Kalent chains waves on its own until the call stops. You never manage waves; only `talent_judged` events and GET show their results. |

A call stops at the first of: `target.greens` greens found during this call, `target.maxCredits` spent, no more matching talents, or a failed wave.

## Scoring language

Every verdict text of a search (`recap`, criterion `title`, criterion `result`) is written in one language, `en` or `fr`. It is resolved once, when the search starts: the optional `scoringLanguage` of the start request, otherwise the language of the user who owns the API key, set at [app.kalent.ai/settings/profile](https://app.kalent.ai/settings/profile) (English when unset). The resolved value is stored on the search, returned as `scoringLanguage` by GET, and reused by every continue; sending `scoringLanguage` on a continue returns `validation_error`. Quotes in `references` are copied verbatim from the profile and are never translated.

The language is part of what identifies a search. The same filters or prompt and criteria in another language start a **new** search, with a new `castingId`, a fresh analysis, and new credits: verdicts are never translated after the fact. Changing the profile language only changes the default of the next start; running searches keep theirs.

## The flow

1. **Start** with [`POST /v1/search/talents/qualified`](/api-reference/search-talents-qualified) or [`…/by-prompt`](/api-reference/search-talents-qualified-by-prompt). The response is `202` with a `castingId` and `status: running`.
2. **Follow the call**: poll [`GET /v1/search/talents/qualified/{castingId}`](/api-reference/get-qualified-search) every 10–20 seconds while `nextAction` is `wait`, or wait for the end-of-call [webhook](#webhooks). GET never costs credits.
3. **Act on `nextAction`**: [`POST …/continue`](/api-reference/continue-qualified-search) when it is `continue` (open a new call) or `retry` (rerun the failed wave). Stop when it is `done`.

| `status`           | `nextAction` | Meaning                                                                                                            |
| ------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------ |
| `running`          | `wait`       | A call is analysing talents.                                                                                       |
| `can_continue`     | `continue`   | The call stopped (green quota or credit cap reached) and more matching talents exist. A continue opens a new call. |
| `failed_retryable` | `retry`      | The last wave failed. A continue reruns it with what is left of the same call's budget.                            |
| `pool_exhausted`   | `done`       | No more matching talents. A continue returns `409 casting_pool_exhausted`.                                         |

`canContinue` on GET is kept for compatibility and equals `nextAction === "continue"`.

## Credits

* **Two credits per judged talent**, whatever the verdict, red included. Charged at the end of each wave. Talents that Kalent opens but cannot judge, and talents never judged because the green quota was met first, are not charged.
* **Reservation.** When a call opens, Kalent reserves `min(workspace balance, target.maxCredits)` from your balance and sizes the first wave on that reservation. Each following wave of the same call reserves what is left of the call's budget, again capped by the balance at that moment.
* **Return of unused credits.** When a wave ends, the credits of its reservation that were not charged go back to the balance. So the balance dips by the reservation right after start or continue, then settles to what was actually charged.
* **Not enough credits.** A start or continue that cannot reserve at least 2 credits fails with `402 insufficient_api_credits`. When the balance is lower than `target.maxCredits`, the call simply spends less; nothing fails.

Three numbers describe the money:

| Where                                         | Field                     | Meaning                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| --------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| GET, end-of-call webhook                      | `creditsCharged`          | Total charged on this search so far, across all calls.                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| GET only                                      | `creditsRemaining`        | What the current call can still spend: the remaining budget of the call, bounded by what your workspace can pay (credits already reserved for this call plus your available balance). `0` once the call is over. Because the balance is shared with your other calls and with talent search, this value can go up as well as down while the call runs. It is not a spending counter: read `creditsCharged` to know what was spent, do not subtract `creditsRemaining` from `target.maxCredits`. |
| Response header of start, by-prompt, continue | `X-Api-Credits-Remaining` | Your workspace balance right after the request, that is after the call's reservation was taken. It goes back up when waves return their unused credits. GET does not send this header.                                                                                                                                                                                                                                                                                                          |

### Worked example

Your balance is 200. You start with `target: { greens: 1, maxCredits: 4 }`.

1. Kalent reserves `min(200, 4) = 4`. The start response carries `X-Api-Credits-Remaining: 196` and `status: running`.
2. The first wave may open up to two talents (4 credits ÷ 2), but with one green still needed it judges them one at a time. The first judged talent is green: the call has its green and stops there. No second judgement is started.
3. GET now returns `status: can_continue`, `nextAction: continue`, `canContinue: true`, `counts: { green: 1, amber: 0, red: 0 }`, `creditsCharged: 2`, `creditsRemaining: 0`.
4. The 2 unused reserved credits are returned: your balance is 198.

### Why a call never overpays

Kalent never judges more talents in parallel than greens the call still needs: with one green missing, judgements run one at a time; with three missing, at most three run at once; as each green lands the allowance shrinks, and once the quota is met nothing else is started and nothing is left in flight. So a call never pays for a judgement started after its green quota was met. Amber and red talents judged **before** that point are charged like any other verdict, the call never exceeds `target.maxCredits`, and the price of this guarantee is time: the smaller the remaining green quota, the less parallel the last judgements, and the slower the end of the call.

## Edge cases

* **Retry of a failed wave.** A continue while `nextAction` is `retry` reruns the failed wave inside the same call: same call index, budget = what the call has not spent yet, again capped by the balance. Talents already charged on that wave are not charged again. If the balance is empty, GET keeps `nextAction: retry` with `creditsRemaining: 0`, and the continue answers `402 insufficient_api_credits` until credits are added.
* **Retry with nothing left to judge.** If the failed call had already found its `target.greens`, the continue does not start a wave: it closes the call and answers `200` with `status: can_continue`, `nextAction: continue`. The next continue opens a new call.
* **Start whose launch failed.** If the search was created but its first wave could not start, the next identical start (same filters or prompt and criteria, same resolved scoring language, no `forceNew`) relaunches the first call on the same `castingId` and answers `202` with `reused: true`.
* **`forceNew`.** Without it, a start whose filters or prompt, criteria, and resolved scoring language match an existing search returns that search (`reused: true`; `200` if it is not running). With `forceNew: true`, Kalent always creates a new search.
* **Concurrent continues.** A continue while a call is running returns `409 casting_run_in_progress`; a second start for the same request while the first is being created returns `409 casting_start_in_progress`.
* **Partial `target` or `webhook` on continue.** Each nested field is independent: a field you omit keeps its stored value, `{ "target": { "greens": 20 } }` leaves the credit cap untouched. Omitting the whole object keeps everything.
* **GET `verdicts` filter.** It only filters the `talents` array. `counts` stay the totals for the whole search and nothing is charged.

## Webhooks

Set `webhook.url` on start (or replace it on continue) to receive events instead of polling. The URL must be HTTPS and must not point to `localhost`, a private network address, or a cloud metadata endpoint; other URLs are rejected with `400 validation_error`.

### Events

| `eventType`      | When                                                                                                                            | `data`                                                               |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `talent_judged`  | A talent received a verdict, and that verdict is in `webhook.verdicts`                                                          | `{ talent }`, the same object as one item of the GET `talents` array |
| `wave_finished`  | A call stopped: green quota or credit cap reached, or a wave failed (`data.status: failed_retryable`, `data.nextAction: retry`) | `{ status, nextAction, counts, creditsCharged }`                     |
| `pool_exhausted` | A call stopped because no matching talent is left                                                                               | `{ status, nextAction, counts, creditsCharged }`                     |

Every event shares the same envelope: `eventType`, `castingId`, `customParams` (the object stored on the search, or `null`), `sentAt` (ISO 8601), and `data`. Kalent sends **one** end-of-call event per call, to the URL stored on the search at that moment; nothing is sent between the internal waves of a call. Filtering `webhook.verdicts` changes neither the analysis nor the bill, and `counts` in the end event are always the full totals. `creditsRemaining` is not part of the webhook; read it on GET.

### Delivery

| Header                 | Value                                                                |
| ---------------------- | -------------------------------------------------------------------- |
| `Content-Type`         | `application/json`                                                   |
| `X-Kalent-Event`       | `talent_judged`, `wave_finished`, or `pool_exhausted`                |
| `X-Kalent-Delivery-Id` | Identifier of this delivery, stable across retries of the same event |
| `X-Kalent-Source-Type` | `api_casting_run`                                                    |
| `X-Kalent-Source-Id`   | Identifier of the internal run that produced the event               |

* **Expected response.** Any `2xx` within **5 seconds**. Redirects are not followed. The response body is ignored.
* **Retries.** A network error, a timeout, `408`, `429`, `5xx`, or any other non-`2xx` response that is not a `4xx` is retried: at most **3 attempts** in total, about 1 second then 5 seconds apart. Any other `4xx` marks the delivery failed with no retry. Retries happen in the same process that produced the event; if that process stops, the remaining attempts are not resumed later.
* **Idempotency.** Each event is delivered at most once per search: one `talent_judged` per talent and run, one end-of-call event per call. All attempts of one event carry the same `X-Kalent-Delivery-Id`, so deduplicate on it if you may receive the same attempt twice.
* **Ordering.** Deliveries are sent as events happen and independently retried, so a `talent_judged` can arrive after the `wave_finished` of its call. Treat GET as the source of truth and webhooks as notifications.

### Authenticating webhooks

Kalent does not sign webhook payloads. Put a secret value in `customParams` when you start the search (for example `{ "webhookSecret": "…" }`): it is stored on the search and echoed unchanged on every event, so your endpoint can reject any request that does not carry it. Rotate it with a continue that sends new `customParams`.
