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

# Integration flow

> The eight steps every integration follows, with the rules for requirements, runs, reveals and decisions.

This page walks through the eight steps of an integration and the rules attached to each. Full request and response schemas are in the [API reference](/api-reference/introduction); errors, rate limits and retries are on [their own page](/errors). RFC 2119 keywords apply.

## Conventions

* **Base URL:** written here as `{BASE_URL}`. Every path is relative to `{BASE_URL}/v1`; `POST /runs` means `POST {BASE_URL}/v1/runs`. The sandbox base URL is on the [Introduction](/introduction#base-urls); the production base URL is issued at go-live.
* **Server-to-server only.** Every request comes from your backend. A browser MUST NOT call this API and MUST NOT ever hold a `vpk_…` key or `vot_…` token.
* **JSON only.** Send `Content-Type: application/json` with a UTF-8 body on every `POST`. Responses are `application/json; charset=utf-8`.
* **Timestamps** are ISO 8601 in UTC, for example `2026-10-04T09:12:44Z`.
* **Ids.** `run_id`, `job_id`, `user_id`, `organization_id` are UUID strings. `candidate_id` is an integer (it can exceed 2^31; use a 64-bit type). `external_id` values are your own ids, as strings, unique per customer organization for users and jobs, and unique per partner for organizations.
* **Unknown fields.** Every response and webhook body MAY gain new fields at any time. Your client MUST ignore fields it does not know. Do not use strict deserializers that fail on extra properties.
* **Language.** Notice `title`/`body` and error `message` follow `x-vort-lang`. Requirement and job validation issues always carry both `_he` and `_en` texts.

## The 8 steps

<Steps>
  <Step title="Connect the customer">
    Vort issues the customer a one-time activation code (`VORT-XXXX-XXXX`, valid 14 days by default). A customer admin pastes it into your "Connect Vort" screen. Your server redeems it with `POST /activations/redeem` and receives an **org token** (`vot_…`). Store it server-side, encrypted, keyed to that customer. It is shown to you once.

    Details: [Authentication](/authentication) · [Redeem activation code](/api-reference/activation/redeem)
  </Step>

  <Step title="Register users">
    For each recruiter who will use Vort, call `POST /users` with your own id for them (`external_id`). A user is a field on later calls, never a credential: recruiters never receive a Vort key or token.

    Details: [Register a recruiter](/api-reference/users/register)
  </Step>

  <Step title="Create the job">
    `POST /jobs` with your job's `external_id`, title, description (plain text, strip HTML) and location. Send it again whenever the job changes in your ATS: a repeat call updates `title`, `description_text` and `location_text` and returns the same `job_id`. Runs already started keep the description they started with. On `422 job_rejected`, show each `rejected[]` message next to its field (`title_missing`, `description_missing`, `location_missing`).

    Details: [Create or update a job](/api-reference/jobs/upsert)
  </Step>

  <Step title="Start a run">
    `POST /runs` with the job, the acting recruiter, a target count and, optionally, the recruiter's requirements (each `must` or `nice`, at most 3 musts). You get `202 {run_id}` immediately, or `422` with Vort's messages if a requirement is certainly wrong. A cold run typically shows its first confirmed candidates within 10 to 17 seconds and keeps working for several minutes.

    See [Requirement rules](#requirement-rules) below. Details: [Start a run](/api-reference/runs/start)
  </Step>

  <Step title="Show progress and results">
    Poll `GET /runs/{run_id}`. While the run is `running`, show `progress.confirmed / progress.target`. Render every entry in `notices[]` in its slot (see [Notices](/notices)). Show candidates with their verdict and per-requirement evidence. Show the `run_id`.

    See [Run status](#run-status) below. Details: [Get a run](/api-reference/runs/get)
  </Step>

  <Step title="Reveal contact details">
    When a recruiter wants contact details, show the price from `reveal_price_credits` before the click, then call `POST /runs/{run_id}/candidates/{candidate_id}/reveal`.

    See [Reveal rules](#reveal-rules) below. Details: [Reveal contact details](/api-reference/candidates/reveal)
  </Step>

  <Step title="Report decisions">
    Every shortlist, rejection (with a reason from the fixed list), contact, interview, hire and note goes to `POST /runs/{run_id}/decisions`, together with the notice codes the recruiter was shown (`notices_shown`).

    See [Decisions and reject reasons](#decisions-and-reject-reasons) below. Details: [Report decisions](/api-reference/decisions/report)
  </Step>

  <Step title="Receive webhooks">
    Vort posts `run_completed`, `run_failed` and `candidate_revealed` (one per revealed candidate) to your webhook URL, signed with `x-vort-signature`. Verify the signature on the raw body before parsing, and de-duplicate retries by `event_id`.

    Details: [Webhooks](/webhooks)
  </Step>
</Steps>

## Requirement rules

Requirements are validated before the run starts.

* `kind` is `must` or `nice`. **At most 3 `must` requirements** (`too_many_musts`). Surface this cap in your editor before submit (see [Screen 2](/ui-specification#screen-2-job-and-requirements-editor)).
* Vort rejects only what is certainly wrong and warns on what is probably wrong. It never silently changes or drops a requirement: the run uses your requirements in the same order and count, trimmed and with whitespace collapsed.
* When `requirements` is omitted, Vort derives the requirements from the job description.
* Requirement indexes (`index`) are 0-based positions in the array you sent. They are the same indexes used by `candidates[].requirements[].index` and by notices with `ref.requirement_index`.

| Type | Codes |
| - | - |
| Rejection (`422 requirements_rejected`, nothing started) | `empty_text`, `invalid_kind`, `too_many_musts`, `duplicate`, `too_long`, `location_as_requirement` (a location written as a requirement; the job's `location_text` is where location belongs) |
| Warning (`202`, the run starts) | `short_term_may_be_dropped`, `years_band_ceiling`, `unverifiable_must` |

<Info>
  Show each rejected item's message and, when not null, its suggestion, next to the requirement at `index`, verbatim, and let the recruiter fix and resubmit. Warnings do not block the run; show them next to the requirement without blocking. Example payloads for every code are in [Requirement validation examples](/sandbox#requirement-validation-examples).
</Info>

<Warning>
  `POST /runs` is not idempotent. Do not retry it automatically on a timeout or `5xx`: show the recruiter an error with a manual "Try again".
</Warning>

## Run status

| `status` | Meaning | Poll? |
| - | - | - |
| `running` | The run is working. `candidates` grows; `notices` may change. | Yes, every 5 s while a recruiter is watching. |
| `completed` | The run stopped. It may have fewer than `target` candidates (pool exhausted, run ceiling reached); the notices say why. | No. |
| `failed` | The run could not complete. A notice (typically `function_error`, slot `empty_state`) explains it. Any candidates already confirmed remain valid. | No. |

Treat any other `status` value as `running`, but stop polling a run that has been running for more than 30 minutes and show the recruiter the `run_id` with a "contact support" hint.

<ResponseField name="progress" type="object">
  <Expandable title="properties" defaultOpen>
    <ResponseField name="confirmed" type="integer">
      Candidates confirmed so far (the list length grows toward this).
    </ResponseField>

    <ResponseField name="target" type="integer">
      The run's target (`target_count`).
    </ResponseField>

    <ResponseField name="screened" type="integer">
      Candidates examined so far. Secondary information.
    </ResponseField>
  </Expandable>
</ResponseField>

## Reveal rules

* Before calling reveal, your UI MUST have shown the recruiter the price from that candidate's `reveal_price_credits` in the latest `GET /runs/{run_id}` response. Never hard-code or cache a price across runs; prices come from Vort's price table at request time.
* `email` and `phone` can each be `null`: not every candidate has both. Render a missing value as "not found", never as an empty field.
* Show `credits_charged` and `wallet_balance_after` after the reveal. Do not assume `credits_charged` equals the quoted price; display what the response says.
* After a reveal, the candidate's `contact_state` becomes `revealed` in later `GET /runs/{run_id}` responses. A repeat reveal of the same candidate is not charged again.
* On `402 insufficient_credits` (with `balance` and `required`), show `message` with both numbers and do not retry.

## Decisions and reject reasons

<Warning>
  Reporting decisions is a condition of the partnership, not optional telemetry. Every recruiter decision in your UI about a Vort candidate MUST be sent to `POST /runs/{run_id}/decisions`, and every notice rendered to a recruiter MUST be reported in `notices_shown`.
</Warning>

**Decision values:** `shortlisted`, `rejected`, `contacted`, `interviewing`, `hired`.

### Reject reasons

Only with `decision: "rejected"`. Your reject UI MUST offer exactly this list, with these labels.

| `reject_reason` | Label (en) | Label (he) |
| - | - | - |
| `cv_irrelevant` | CV not relevant | קו״ח לא רלוונטיים |
| `overqualified` | Overqualified | מנוסה מדי |
| `location` | Distance / location | מרחק/מיקום |
| `insufficient_experience` | Insufficient experience | ניסיון חסר |
| `declined` | Candidate declined | המועמד סירב |
| `candidate_withdrew` | Candidate withdrew | המועמד נסוג |
| `known` | Already known to us | מוכר לנו כבר |

* A rejection MAY have `reject_reason: null` only when the recruiter did not pick a reason; put any free text in `note`. Never infer a reason code from free text.
* `reject_reason` on any decision other than `rejected` is `422 invalid_decision`.
* **Free notes:** a recruiter's note travels in `note` on the decision it belongs to. v1 has no standalone note without a decision.
* `occurred_at` is when the recruiter acted in your UI, not when you sent it. You MAY batch decisions, but SHOULD send them promptly (Vort recommends within 5 minutes of the action).
* `notices_shown` lists the notice `code`s rendered to a recruiter for this run, including codes you did not recognize. Send each code at least once per run; repeats are harmless. If the recruiter makes no decision, still send `decisions: []` with `notices_shown`.
* The response is `{"accepted": n, "duplicates": n}`. Treat any `422` as "nothing from this request was stored": fix the batch and resend all of it. Items already stored come back as `duplicates`.

```json Request body theme={null}
{
  "acting_user_external_id": "u-88121",
  "decisions": [
    { "candidate_id": 48213377, "decision": "shortlisted", "occurred_at": "2026-10-04T09:15:02Z" },
    { "candidate_id": 50119020, "decision": "rejected", "reject_reason": "location", "note": "Lives in Haifa, will not commute.", "occurred_at": "2026-10-04T09:15:40Z" },
    { "candidate_id": 48213377, "decision": "contacted", "note": "Sent InMail", "occurred_at": "2026-10-04T11:02:10Z" }
  ],
  "notices_shown": ["location_relaxed", "requirement_attribution", "run_progress"]
}
```

## Objects

<AccordionGroup>
  <Accordion title="Candidate" icon="user">
    <ResponseField name="id" type="integer">The `candidate_id` for reveal and decisions.</ResponseField>
    <ResponseField name="name" type="string">Render as an opaque string (masked in the sandbox).</ResponseField>
    <ResponseField name="title" type="string | null">Current title.</ResponseField>
    <ResponseField name="company" type="string | null">Current employer.</ResponseField>
    <ResponseField name="location" type="string | null">Location.</ResponseField>
    <ResponseField name="verdict" type="string">`strong` | `good` | `weak`</ResponseField>

    <ResponseField name="requirements" type="object[]">
      One entry per run requirement.

      <Expandable title="properties">
        <ResponseField name="index" type="integer">0-based requirement index.</ResponseField>
        <ResponseField name="text" type="string">The requirement text.</ResponseField>
        <ResponseField name="kind" type="string">`must` | `nice`</ResponseField>
        <ResponseField name="met" type="boolean | null">`null` means not determinable from the profile.</ResponseField>
        <ResponseField name="evidence" type="string | null">Vort's reason for the verdict on that requirement. Render verbatim.</ResponseField>
      </Expandable>
    </ResponseField>

    <ResponseField name="contact_state" type="string">`locked` | `revealed`</ResponseField>
    <ResponseField name="reveal_price_credits" type="integer">Price of a reveal, in credits, at the time of this response.</ResponseField>
  </Accordion>

  <Accordion title="Notice" icon="message-square-warning">
    <ResponseField name="code" type="string">Stable identifier. **New codes appear without notice; render them generically.** See [Notices](/notices).</ResponseField>
    <ResponseField name="severity" type="string">`info` | `warning` | `blocking`</ResponseField>
    <ResponseField name="slot" type="string">`above_list` | `on_candidate` | `on_requirement` | `empty_state`</ResponseField>
    <ResponseField name="title" type="string">Short heading. Render verbatim.</ResponseField>
    <ResponseField name="body" type="string">Full sentence(s). Render verbatim.</ResponseField>

    <ResponseField name="ref" type="object">
      Optional. `requirement_index` (for `on_requirement`) and/or `candidate_id` (for `on_candidate`).
    </ResponseField>
  </Accordion>

  <Accordion title="RequirementInput" icon="list-checks">
    <ResponseField name="text" type="string" required>The requirement as the recruiter wrote it.</ResponseField>
    <ResponseField name="kind" type="string" required>`must` | `nice`</ResponseField>
  </Accordion>

  <Accordion title="RequirementIssue" icon="circle-alert">
    Used in `rejected[]` and `warnings[]` of `POST /runs`.
    <ResponseField name="index" type="integer">0-based position in the submitted `requirements` array.</ResponseField>
    <ResponseField name="code" type="string">A rejection or warning code (see [Requirement rules](#requirement-rules)). New codes may be added.</ResponseField>
    <ResponseField name="message_he, message_en" type="string">What is wrong. Show verbatim.</ResponseField>
    <ResponseField name="suggestion_he, suggestion_en" type="string | null">How to fix it. Show verbatim when not null.</ResponseField>
  </Accordion>

  <Accordion title="JobIssue" icon="briefcase">
    <ResponseField name="field" type="string">The request field at fault, for example `location_text`.</ResponseField>
    <ResponseField name="code" type="string">`title_missing` | `description_missing` | `location_missing` (new codes may be added).</ResponseField>
    <ResponseField name="message_he, message_en" type="string">Show verbatim.</ResponseField>
  </Accordion>

  <Accordion title="Decision" icon="gavel">
    <ResponseField name="candidate_id" type="integer" required>A candidate of this run.</ResponseField>
    <ResponseField name="decision" type="string" required>`shortlisted` | `rejected` | `contacted` | `interviewing` | `hired`</ResponseField>
    <ResponseField name="reject_reason" type="string | null">Only with `rejected`. One of the seven [reject reasons](#reject-reasons).</ResponseField>
    <ResponseField name="note" type="string | null">The recruiter's free text.</ResponseField>
    <ResponseField name="occurred_at" type="string (date-time)" required>When the recruiter acted.</ResponseField>
  </Accordion>
</AccordionGroup>
