> ## 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.

# Quickstart

> Take one customer from activation code to a reported decision against the sandbox.

export const SANDBOX_BASE = "https://vort-partner-api.vercel.app/sandbox";

This walkthrough covers steps 1 to 7 of [the integration flow](/integration-flow) against the sandbox, with `curl` and [`jq`](https://jqlang.org/) and the same calls in Node.js, Python and PHP (Laravel). Webhooks are step 8 and have [their own page](/webhooks).

<Warning>
  Run these commands from a terminal or from your server. **Never call the Vort API from a browser**: the partner key and org tokens are server-side secrets (see [Authentication](/authentication)).
</Warning>

## Before you start

You need, from Vort:

* a **sandbox partner key** (the value you send in `x-partner-key`);
* an **activation code** for your sandbox customer organization (`VORT-XXXX-XXXX`).

<Info>
  The sandbox runs real searches but returns synthetic contact details and never charges real credits. Keep `target_count` at 20 or below. See [Sandbox](/sandbox) for every difference from production.
</Info>

<Steps>
  <Step title="Configure your environment">
    The sandbox base URL is <code>{SANDBOX_BASE}/v1</code>. `VORT_BASE` includes `/v1`, so every call below is `$VORT_BASE/<path>`.

    <CodeBlock language="bash" filename="Shell">
      {`export VORT_BASE="${SANDBOX_BASE}/v1"\nexport VORT_PARTNER_KEY="<your sandbox partner key>"   # from Vort; never commit it`}
    </CodeBlock>

    The Node.js, Python and PHP examples on this page call a small helper that reads the same two values and adds the headers. Every request sends `x-partner-key`; every request except the redeem call also sends the customer's org token in `x-vort-org`.

    <CodeGroup>
      ```javascript Node.js theme={null}
      // vort.js (server side only, Node.js 18+)
      const BASE = process.env.VORT_BASE; // includes /v1
      const PARTNER_KEY = process.env.VORT_PARTNER_KEY;

      export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

      export async function vort(method, path, { org, body, lang = "en" } = {}) {
        const headers = { "x-partner-key": PARTNER_KEY, "x-vort-lang": lang };
        if (org) headers["x-vort-org"] = org;
        if (body !== undefined) headers["Content-Type"] = "application/json";
        const res = await fetch(`${BASE}${path}`, {
          method,
          headers,
          body: body === undefined ? undefined : JSON.stringify(body),
        });
        const data = await res.json().catch(() => null); // ignore fields you do not know
        return { status: res.status, headers: res.headers, data };
      }
      ```

      ```python Python theme={null}
      # vort.py (server side only)
      import os
      import requests

      BASE = os.environ["VORT_BASE"]  # includes /v1
      PARTNER_KEY = os.environ["VORT_PARTNER_KEY"]


      def vort(method, path, org=None, body=None, lang="en"):
          headers = {"x-partner-key": PARTNER_KEY, "x-vort-lang": lang}
          if org:
              headers["x-vort-org"] = org
          return requests.request(method, BASE + path, headers=headers, json=body, timeout=20)
      ```

      ```php PHP (Laravel) theme={null}
      <?php
      // app/Support/Vort.php (server side only)
      // config/services.php: 'vort' => ['base' => env('VORT_BASE'), 'partner_key' => env('VORT_PARTNER_KEY')]
      namespace App\Support;

      use Illuminate\Support\Facades\Http;

      class Vort
      {
          public static function request(string $method, string $path, ?string $org = null, ?array $body = null, string $lang = 'en')
          {
              $headers = ['x-partner-key' => config('services.vort.partner_key'), 'x-vort-lang' => $lang];
              if ($org !== null) {
                  $headers['x-vort-org'] = $org;
              }
              $options = $body === null ? [] : ['json' => $body];

              return Http::withHeaders($headers)
                  ->acceptJson()
                  ->timeout(20)
                  ->send($method, config('services.vort.base') . $path, $options);
          }
      }
      ```
    </CodeGroup>
  </Step>

  <Step title="Connect a customer">
    Redeem the activation code. This is the only call made without `x-vort-org`.

    <CodeGroup>
      ```bash curl theme={null}
      curl -sS -X POST "$VORT_BASE/activations/redeem" \
        -H "x-partner-key: $VORT_PARTNER_KEY" \
        -H "Content-Type: application/json" \
        -d '{"code":"VORT-XXXX-XXXX","external_id":"quickstart-tenant-1"}' \
        | tee redeem.json
      ```

      ```javascript Node.js theme={null}
      import { vort } from "./vort.js";

      const { status, data } = await vort("POST", "/activations/redeem", {
        body: { code: "VORT-XXXX-XXXX", external_id: "quickstart-tenant-1" },
      });
      if (status !== 200) throw new Error(`${status} ${data?.error}: ${data?.message}`);

      // Returned once. Store it server-side, encrypted at rest, keyed by your external_id.
      await saveOrgTokenEncrypted("quickstart-tenant-1", data.org_token);
      ```

      ```python Python theme={null}
      from vort import vort

      res = vort("POST", "/activations/redeem",
                 body={"code": "VORT-XXXX-XXXX", "external_id": "quickstart-tenant-1"})
      data = res.json()
      if res.status_code != 200:
          raise RuntimeError(f"{res.status_code} {data.get('error')}: {data.get('message')}")

      # Returned once. Store it server-side, encrypted at rest, keyed by your external_id.
      save_org_token_encrypted("quickstart-tenant-1", data["org_token"])
      ```

      ```php PHP (Laravel) theme={null}
      use App\Support\Vort;

      $res = Vort::request('POST', '/activations/redeem', null, [
          'code' => 'VORT-XXXX-XXXX',
          'external_id' => 'quickstart-tenant-1',
      ]);
      if ($res->status() !== 200) {
          throw new \RuntimeException($res->status() . ' ' . $res->json('error') . ': ' . $res->json('message'));
      }

      // Returned once. Store it server-side, encrypted at rest, keyed by your external_id.
      $tenant->vort_org_token = encrypt($res->json('org_token'));
      $tenant->save();
      ```
    </CodeGroup>

    ```json Response theme={null}
    {
      "organization_id": "0b8f4a52-2d1e-4c7b-9a61-5f0e3c2d9b17",
      "org_token": "vot_9c1e...<48 hex total>",
      "organization_name": "Acme Staffing Ltd"
    }
    ```

    <Warning>
      The org token is returned **once**. A second redeem of the same code returns `409 code_already_redeemed` and does not return the token again, which is why the `curl` response is saved with `tee`. Keep the token and delete the file.
    </Warning>

    ```bash theme={null}
    export VORT_ORG="$(jq -r .org_token redeem.json)"
    rm redeem.json
    ```

    In your product, store the token server-side, encrypted at rest, keyed by the `external_id` you sent (your id for this customer).
  </Step>

  <Step title="Register a recruiter">
    <CodeGroup>
      ```bash curl theme={null}
      curl -sS -X POST "$VORT_BASE/users" \
        -H "x-partner-key: $VORT_PARTNER_KEY" \
        -H "x-vort-org: $VORT_ORG" \
        -H "Content-Type: application/json" \
        -d '{"external_id":"u-quickstart-1","email":"recruiter@your-ats.example","full_name":"Quickstart Recruiter","role":"recruiter"}'
      ```

      ```javascript Node.js theme={null}
      const org = await loadOrgToken("quickstart-tenant-1"); // from your server-side store

      const { data: user } = await vort("POST", "/users", {
        org,
        body: {
          external_id: "u-quickstart-1",
          email: "recruiter@your-ats.example",
          full_name: "Quickstart Recruiter",
          role: "recruiter",
        },
      });
      ```

      ```python Python theme={null}
      org = load_org_token("quickstart-tenant-1")  # from your server-side store

      user = vort("POST", "/users", org=org, body={
          "external_id": "u-quickstart-1",
          "email": "recruiter@your-ats.example",
          "full_name": "Quickstart Recruiter",
          "role": "recruiter",
      }).json()
      ```

      ```php PHP (Laravel) theme={null}
      $org = decrypt($tenant->vort_org_token);

      $user = Vort::request('POST', '/users', $org, [
          'external_id' => 'u-quickstart-1',
          'email' => 'recruiter@your-ats.example',
          'full_name' => 'Quickstart Recruiter',
          'role' => 'recruiter',
      ])->json();
      ```
    </CodeGroup>

    ```json Response theme={null}
    { "user_id": "5e0a9d63-8b7c-4c36-9e0d-6a2f41b7c3aa", "created": true }
    ```

    Later calls identify the recruiter by your `external_id` (`acting_user_external_id`). It is a field, not a credential. `POST /users` is an upsert and safe to retry.
  </Step>

  <Step title="Create the job">
    <CodeGroup>
      ```bash curl theme={null}
      curl -sS -X POST "$VORT_BASE/jobs" \
        -H "x-partner-key: $VORT_PARTNER_KEY" \
        -H "x-vort-org: $VORT_ORG" \
        -H "Content-Type: application/json" \
        -d '{
          "external_id": "job-quickstart-1",
          "title": "Senior Backend Engineer",
          "description_text": "We are looking for a senior backend engineer to own our payments platform. 5+ years with Go or Java, PostgreSQL, Kafka.",
          "location_text": "Tel Aviv"
        }'
      ```

      ```javascript Node.js theme={null}
      const { data: job } = await vort("POST", "/jobs", {
        org,
        body: {
          external_id: "job-quickstart-1",
          title: "Senior Backend Engineer",
          description_text:
            "We are looking for a senior backend engineer to own our payments platform. 5+ years with Go or Java, PostgreSQL, Kafka.",
          location_text: "Tel Aviv",
        },
      });
      ```

      ```python Python theme={null}
      job = vort("POST", "/jobs", org=org, body={
          "external_id": "job-quickstart-1",
          "title": "Senior Backend Engineer",
          "description_text": "We are looking for a senior backend engineer to own our payments platform. "
                              "5+ years with Go or Java, PostgreSQL, Kafka.",
          "location_text": "Tel Aviv",
      }).json()
      ```

      ```php PHP (Laravel) theme={null}
      $job = Vort::request('POST', '/jobs', $org, [
          'external_id' => 'job-quickstart-1',
          'title' => 'Senior Backend Engineer',
          'description_text' => 'We are looking for a senior backend engineer to own our payments platform. 5+ years with Go or Java, PostgreSQL, Kafka.',
          'location_text' => 'Tel Aviv',
      ])->json();
      ```
    </CodeGroup>

    ```json Response theme={null}
    { "job_id": "c3d1f9e2-6a47-4b0b-8f5e-2b9e7a4d1c06", "created": true }
    ```

    Send `POST /jobs` again whenever the job changes in your ATS: it updates the title, description and location and returns the same `job_id`.
  </Step>

  <Step title="Start a run">
    <CodeGroup>
      ```bash curl theme={null}
      curl -sS -X POST "$VORT_BASE/runs" \
        -H "x-partner-key: $VORT_PARTNER_KEY" \
        -H "x-vort-org: $VORT_ORG" \
        -H "x-vort-lang: en" \
        -H "Content-Type: application/json" \
        -d '{
          "job_external_id": "job-quickstart-1",
          "acting_user_external_id": "u-quickstart-1",
          "target_count": 10,
          "requirements": [
            { "text": "5+ years of backend development in Go or Java", "kind": "must" },
            { "text": "Production experience with Kafka", "kind": "must" },
            { "text": "Payments or fintech domain", "kind": "nice" }
          ]
        }' | tee run-start.json

      export RUN_ID="$(jq -r .run_id run-start.json)"
      ```

      ```javascript Node.js theme={null}
      const started = await vort("POST", "/runs", {
        org,
        body: {
          job_external_id: "job-quickstart-1",
          acting_user_external_id: "u-quickstart-1",
          target_count: 10,
          requirements: [
            { text: "5+ years of backend development in Go or Java", kind: "must" },
            { text: "Production experience with Kafka", kind: "must" },
            { text: "Payments or fintech domain", kind: "nice" },
          ],
        },
      });
      // 202: started (show started.data.warnings). 422: nothing started (show rejected[]).
      // Never retry this call automatically: every accepted call starts a new run.
      const runId = started.data.run_id;
      ```

      ```python Python theme={null}
      started = vort("POST", "/runs", org=org, body={
          "job_external_id": "job-quickstart-1",
          "acting_user_external_id": "u-quickstart-1",
          "target_count": 10,
          "requirements": [
              {"text": "5+ years of backend development in Go or Java", "kind": "must"},
              {"text": "Production experience with Kafka", "kind": "must"},
              {"text": "Payments or fintech domain", "kind": "nice"},
          ],
      })
      # 202: started (show warnings). 422: nothing started (show rejected[]).
      # Never retry this call automatically: every accepted call starts a new run.
      run_id = started.json()["run_id"]
      ```

      ```php PHP (Laravel) theme={null}
      $started = Vort::request('POST', '/runs', $org, [
          'job_external_id' => 'job-quickstart-1',
          'acting_user_external_id' => 'u-quickstart-1',
          'target_count' => 10,
          'requirements' => [
              ['text' => '5+ years of backend development in Go or Java', 'kind' => 'must'],
              ['text' => 'Production experience with Kafka', 'kind' => 'must'],
              ['text' => 'Payments or fintech domain', 'kind' => 'nice'],
          ],
      ]);
      // 202: started (show warnings). 422: nothing started (show rejected[]).
      // Never retry this call automatically: every accepted call starts a new run.
      $runId = $started->json('run_id');
      ```
    </CodeGroup>

    ```json Response (202) theme={null}
    {
      "run_id": "9f2b6c1e-3d4a-4e8f-b1c7-0a5d2e9f6b33",
      "status": "running",
      "warnings": [
        {
          "index": 0,
          "code": "years_band_ceiling",
          "message_he": "...",
          "message_en": "\"5+ years\" sets no upper limit. If you meant a range (e.g. 5 to 8), write it explicitly.",
          "suggestion_he": null,
          "suggestion_en": null
        }
      ]
    }
    ```

    * `202` means the run started. `warnings[]` never block; show each one under the requirement at its `index`.
    * `422 requirements_rejected` means nothing started. Show each `rejected[]` message (and suggestion) under its requirement, let the recruiter fix it, and resubmit.

    <Warning>
      **Do not retry `POST /runs` automatically** on a timeout or `5xx`: every accepted call starts a new run. Offer the recruiter a manual "Try again".
    </Warning>
  </Step>

  <Step title="Poll the run">
    Poll `GET /runs/{run_id}` every 5 seconds while a recruiter is watching, and stop when the status is `completed` or `failed`. Honor `Retry-After` on `429`.

    <CodeGroup>
      ```bash curl theme={null}
      while true; do
        code=$(curl -sS -o run.json -D headers.txt -w '%{http_code}' \
          "$VORT_BASE/runs/$RUN_ID" \
          -H "x-partner-key: $VORT_PARTNER_KEY" \
          -H "x-vort-org: $VORT_ORG" \
          -H "x-vort-lang: en")

        if [ "$code" = "429" ]; then
          wait=$(awk 'tolower($1)=="retry-after:" {print $2+0}' headers.txt)
          sleep "${wait:-30}"; continue
        fi
        [ "$code" = "200" ] || { echo "HTTP $code"; cat run.json; break; }

        jq -r '"\(.status)  \(.progress.confirmed)/\(.progress.target) confirmed, \(.progress.screened) screened"' run.json
        case "$(jq -r .status run.json)" in completed|failed) break ;; esac
        sleep 5
      done
      ```

      ```javascript Node.js theme={null}
      import { sleep } from "./vort.js";

      let run;
      while (true) {
        const { status, headers, data } = await vort("GET", `/runs/${runId}`, { org });
        if (status === 429) {
          await sleep(Number(headers.get("Retry-After") ?? 30) * 1000);
          continue;
        }
        if (status !== 200) throw new Error(`${status} ${data?.error}`);
        run = data;
        console.log(`${run.status}  ${run.progress.confirmed}/${run.progress.target} confirmed`);
        if (run.status === "completed" || run.status === "failed") break;
        await sleep(5000);
      }
      ```

      ```python Python theme={null}
      import time

      while True:
          res = vort("GET", f"/runs/{run_id}", org=org)
          if res.status_code == 429:
              time.sleep(int(res.headers.get("Retry-After", "30")))
              continue
          res.raise_for_status()
          run = res.json()
          print(f"{run['status']}  {run['progress']['confirmed']}/{run['progress']['target']} confirmed")
          if run["status"] in ("completed", "failed"):
              break
          time.sleep(5)
      ```

      ```php PHP (Laravel) theme={null}
      while (true) {
          $res = Vort::request('GET', "/runs/{$runId}", $org);
          if ($res->status() === 429) {
              sleep((int) ($res->header('Retry-After') ?: 30));
              continue;
          }
          $res->throw();
          $run = $res->json();
          if (in_array($run['status'], ['completed', 'failed'], true)) {
              break;
          }
          sleep(5);
      }
      ```
    </CodeGroup>

    Then look at what the recruiter would see:

    ```bash theme={null}
    jq '.notices[] | {code, severity, slot, title}' run.json
    jq '.candidates[] | {id, name, verdict, contact_state, reveal_price_credits}' run.json
    ```

    In your UI: show `progress.confirmed / progress.target` as numbers and a bar, render **every** notice in its slot (including codes you do not know), keep the candidates in API order, and show the `run_id`. In the sandbox, candidate names are pseudonyms such as `Candidate 7F3A` until the data processing annex is signed.
  </Step>

  <Step title="Reveal a contact">
    Show the price from `reveal_price_credits` before the click, then reveal.

    <CodeGroup>
      ```bash curl theme={null}
      export CANDIDATE_ID="$(jq -r '.candidates[0].id' run.json)"
      jq -r '.candidates[0].reveal_price_credits' run.json   # the price to show before the click

      curl -sS -X POST "$VORT_BASE/runs/$RUN_ID/candidates/$CANDIDATE_ID/reveal" \
        -H "x-partner-key: $VORT_PARTNER_KEY" \
        -H "x-vort-org: $VORT_ORG" \
        -H "Content-Type: application/json" \
        -d '{"acting_user_external_id":"u-quickstart-1"}'
      ```

      ```javascript Node.js theme={null}
      const candidate = run.candidates[0];
      // Show candidate.reveal_price_credits to the recruiter before the click.
      const { data: contact } = await vort(
        "POST",
        `/runs/${runId}/candidates/${candidate.id}/reveal`,
        { org, body: { acting_user_external_id: "u-quickstart-1" } },
      );
      ```

      ```python Python theme={null}
      candidate = run["candidates"][0]
      # Show candidate["reveal_price_credits"] to the recruiter before the click.
      contact = vort("POST", f"/runs/{run_id}/candidates/{candidate['id']}/reveal", org=org,
                     body={"acting_user_external_id": "u-quickstart-1"}).json()
      ```

      ```php PHP (Laravel) theme={null}
      $candidate = $run['candidates'][0];
      // Show $candidate['reveal_price_credits'] to the recruiter before the click.
      $contact = Vort::request('POST', "/runs/{$runId}/candidates/{$candidate['id']}/reveal", $org, [
          'acting_user_external_id' => 'u-quickstart-1',
      ])->json();
      ```
    </CodeGroup>

    ```json Response (sandbox) theme={null}
    {
      "email": "candidate-48213377@sandbox.vort.invalid",
      "phone": "+972500003377",
      "credits_charged": 0,
      "wallet_balance_after": 2450,
      "sandbox": true
    }
    ```

    In the sandbox the contact details are synthetic and a reveal is never charged (`credits_charged: 0`). In production, `credits_charged` is the real charge. Either way, render `credits_charged` and `wallet_balance_after` exactly as returned, and a `null` e-mail or phone as "not found". A repeat reveal of the same candidate is not charged again.
  </Step>

  <Step title="Report decisions">
    Every recruiter decision about a Vort candidate, and every notice code you rendered, goes back to Vort. This is a condition of the partnership.

    <CodeGroup>
      ```bash curl theme={null}
      NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
      NOTICES="$(jq -c '[.notices[].code] | unique' run.json)"

      jq -n --argjson cid "$CANDIDATE_ID" --arg now "$NOW" --argjson notices "$NOTICES" '{
        acting_user_external_id: "u-quickstart-1",
        decisions: [ { candidate_id: $cid, decision: "shortlisted", occurred_at: $now } ],
        notices_shown: $notices
      }' > decisions.json

      curl -sS -X POST "$VORT_BASE/runs/$RUN_ID/decisions" \
        -H "x-partner-key: $VORT_PARTNER_KEY" \
        -H "x-vort-org: $VORT_ORG" \
        -H "Content-Type: application/json" \
        -d @decisions.json
      ```

      ```javascript Node.js theme={null}
      // occurred_at is when the recruiter acted. Store it and reuse it on a retry.
      const occurredAt = new Date().toISOString();
      const noticesShown = [...new Set(run.notices.map((n) => n.code))];

      const { data: result } = await vort("POST", `/runs/${runId}/decisions`, {
        org,
        body: {
          acting_user_external_id: "u-quickstart-1",
          decisions: [{ candidate_id: candidate.id, decision: "shortlisted", occurred_at: occurredAt }],
          notices_shown: noticesShown,
        },
      });
      ```

      ```python Python theme={null}
      from datetime import datetime, timezone

      # occurred_at is when the recruiter acted. Store it and reuse it on a retry.
      occurred_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
      notices_shown = sorted({n["code"] for n in run["notices"]})

      result = vort("POST", f"/runs/{run_id}/decisions", org=org, body={
          "acting_user_external_id": "u-quickstart-1",
          "decisions": [{"candidate_id": candidate["id"], "decision": "shortlisted", "occurred_at": occurred_at}],
          "notices_shown": notices_shown,
      }).json()
      ```

      ```php PHP (Laravel) theme={null}
      // occurred_at is when the recruiter acted. Store it and reuse it on a retry.
      $occurredAt = gmdate('Y-m-d\TH:i:s\Z');
      $noticesShown = array_values(array_unique(array_column($run['notices'], 'code')));

      $result = Vort::request('POST', "/runs/{$runId}/decisions", $org, [
          'acting_user_external_id' => 'u-quickstart-1',
          'decisions' => [
              ['candidate_id' => $candidate['id'], 'decision' => 'shortlisted', 'occurred_at' => $occurredAt],
          ],
          'notices_shown' => $noticesShown,
      ])->json();
      ```
    </CodeGroup>

    ```json Response theme={null}
    { "accepted": 1, "duplicates": 0 }
    ```

    Send the same request again and you get `{"accepted": 0, "duplicates": 1}`: a retry that reuses `occurred_at` is stored once. A rejection carries one of the seven fixed `reject_reason` values (or `null`) and an optional `note`; see [Decisions and reject reasons](/integration-flow#decisions-and-reject-reasons).
  </Step>
</Steps>

## Next steps

<Columns cols={2}>
  <Card title="Webhooks" icon="webhook" href="/webhooks">
    Receive and verify webhooks instead of polling runs nobody is watching.
  </Card>

  <Card title="UI specification" icon="layout-panel-top" href="/ui-specification">
    Build the screens and render notices by the rules Vort fixes.
  </Card>

  <Card title="Errors and rate limits" icon="triangle-alert" href="/errors">
    Handle errors, rate limits and retries.
  </Card>

  <Card title="Certification" icon="badge-check" href="/certification-fixtures">
    Run the certification fixtures and work through the checklist.
  </Card>
</Columns>
