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

# Sandbox

> Build, test and certify against the same API as production, with synthetic contacts, no credit spend, and every state on demand.

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

The sandbox is where you build, test and certify your integration. It runs the same API as production, at a different base URL, with guard rails that make it safe to experiment: you never receive a real contact detail, you never spend a credit, and you can trigger every state, notice and error on demand. RFC 2119 keywords apply.

## Base URL and credentials

The sandbox base URL is <code>{SANDBOX_BASE}/v1</code>.

Vort sends you a sandbox partner key (`vpk_live_…`), an activation code for a sandbox customer organization, and (on request) your webhook registration at onboarding. Keep the base URL in configuration: the production URL is different and is issued after certification.

Every sandbox response carries the header:

```http theme={null}
x-vort-environment: sandbox
```

A production response never carries it. Log it next to your requests, so a support conversation can tell the two apart at a glance. Webhooks sent by the sandbox carry the same header.

Everything in the [Integration flow](/integration-flow), [Errors](/errors) and the [API reference](/api-reference/introduction) applies unchanged: the headers, the error envelope, rate limits, idempotency, the request log. The differences are listed on this page and on [Certification fixtures](/certification-fixtures), and nowhere else.

## What is real and what is synthetic

| | In the sandbox |
| - | - |
| Runs on your own jobs | **Real.** The same engine and the same candidate pool as production. Titles, employers, locations, verdicts, per-requirement `met` and `evidence`, and notices are what a production run would return. |
| Certification fixtures (`cert-*` jobs) | **Synthetic.** Fixed, deterministic payloads with fake candidates. See [Certification fixtures](/certification-fixtures). |
| Candidate names | **Masked** by default (`Candidate 7F3A`). See [below](#candidate-names-and-identifiers). |
| Contact reveal | **Synthetic.** A fixed fake e-mail and phone per candidate. Nothing is charged, no contact provider is called. |
| `credits_charged` | Always `0`. |
| `wallet_balance_after` | The sandbox organization's balance, which a sandbox reveal never changes. |
| `reveal_price_credits` | The current price, or the value you set with [`x-vort-sandbox-reveal-price`](#reveal-price-override). |
| Decisions and `notices_shown` | **Real.** Stored exactly as in production, so Vort can verify them during certification. |
| Webhooks | **Real deliveries**, signed with your real webhook secret (except the deliberate bad-signature test). |

A sandbox reveal answers `200` with the usual fields plus `"sandbox": true`:

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

The e-mail is always `candidate-<candidate_id>@sandbox.vort.invalid` (the `.invalid` top-level domain can never receive mail) and the phone is always `+97250000` followed by the last four digits of the candidate id. The same candidate always gets the same contact, so you can test that a second reveal is idempotent. After a reveal, `GET /runs/{run_id}` reports that candidate as `contact_state: "revealed"`, and a `candidate_revealed` webhook is queued once per (run, candidate) when your webhook is registered. The sandbox never answers `202 pending` or `402 insufficient_credits` on a reveal; the scoping errors (`run_not_found`, `user_not_found`, `candidate_not_in_run`) behave exactly as in production.

## Candidate names and identifiers

On runs of your own jobs, the sandbox masks direct identifiers before they leave Vort:

* **`name`** becomes a stable pseudonym, `Candidate XXXX` (four hex digits derived from the candidate id; the same candidate always gets the same pseudonym).
* Inside **`evidence`**, the candidate's real name (and each part of it) is replaced by the same pseudonym.
* In **`evidence`, `title`, `company` and `location`**, any e-mail address, URL (including profile, LinkedIn and photo links) and phone number is replaced by `[email hidden in sandbox]`, `[link hidden in sandbox]` or `[phone hidden in sandbox]`.

Everything else stays real: title, company, location, verdict, requirement `met` and `evidence`, notices. That is what you are demoing.

<Note>
  Real names are shown only after the Data Processing Annex is signed, when Vort turns them on for your partner account. Contact identifiers stay masked in the sandbox regardless. Your UI MUST NOT depend on the name format: render `name` as an opaque string.
</Note>

## Sandbox limits

| Limit | Value | Answer when exceeded |
| - | - | - |
| `target_count` per run | at most **20** (production: 100) | `422 sandbox_limit` (+ `max_target_count`) |
| Real runs per customer organization | **20 per rolling 24 hours** | `429 sandbox_run_quota` + `Retry-After` (+ `retry_after_seconds`, `limit`) |
| Request rate | as in production (per partner key) | `429 rate_limited` + `Retry-After` |

Certification fixture runs do not count toward the daily run quota. A `POST /runs` refused with `422` creates nothing and does not count either.

```http theme={null}
HTTP/1.1 429 Too Many Requests
Retry-After: 3600
x-vort-environment: sandbox

{"error":"sandbox_run_quota","message":"The sandbox allows 20 real runs per organization per 24 hours. Retry after 3600 seconds, or use the certification fixtures (cert-*), which do not count.","retry_after_seconds":3600,"limit":20}
```

## Certification fixtures

A `POST /runs` whose `job_external_id` starts with `cert-` never runs the engine. It returns immediately with a deterministic payload you can build and test against. The fixtures, what each returns, and what you must verify are on [Certification fixtures](/certification-fixtures).

## Simulating errors

Add this header to any authenticated sandbox request to get the exact error a real failure of that kind returns. The request is authenticated and logged first, so it appears in Vort's request log like a real failure.

```http theme={null}
x-vort-sandbox-simulate: rate_limited | rate_limit_unavailable | internal_error | insufficient_credits
```

| Value | Status | Body | Headers |
| - | - | - | - |
| `rate_limited` | `429` | `{"error":"rate_limited","message":"Too many requests. Retry after 30 seconds.","retry_after_seconds":30}` | `Retry-After: 30` |
| `rate_limit_unavailable` | `503` | `{"error":"rate_limit_unavailable","message":"The request quota cannot be verified right now. Please retry shortly."}` | |
| `internal_error` | `500` | `{"error":"internal_error","message":"An internal error occurred. Please try again later."}` | |
| `insufficient_credits` | `402` | `{"error":"insufficient_credits","message":"Not enough credits to reveal this contact.","balance":0,"required":15,"reason":"simulated"}` | Same shape as a real reveal refusal. Use it for certification C-14. |

```bash curl theme={null}
curl -sS -i "$VORT_BASE/runs/$RUN_ID" \
  -H "x-partner-key: $VORT_PARTNER_KEY" \
  -H "x-vort-org: $VORT_ORG" \
  -H "x-vort-sandbox-simulate: rate_limited"
```

Any other value answers `422 invalid_request` with the valid values in `allowed`. Use it for certification C-20: a `GET /runs` with `rate_limited` MUST be retried only after `Retry-After`; a `POST /runs` with `rate_limit_unavailable` MUST NOT be retried automatically. Simulated requests count toward your per-minute rate limit like any other request.

## Reveal price override

```http theme={null}
x-vort-sandbox-reveal-price: 25
```

On `GET /runs/{run_id}`, an integer from `0` to `1000` replaces `reveal_price_credits` on every candidate of that response, for that request only. Use it to prove your UI shows the price from the payload and never a stored or hard-coded value (C-13). A reveal still charges `0`. Anything that is not an integer 0 to 1000 answers `422 invalid_request`.

## Test webhooks

```bash curl theme={null}
curl -sS -X POST "$VORT_BASE/sandbox/webhooks/test" \
  -H "x-partner-key: $VORT_PARTNER_KEY" \
  -H "x-vort-org: $VORT_ORG" \
  -H "Content-Type: application/json" \
  -d '{"event":"run_completed","valid_signature":true}'
```

<ParamField body="event" type="string" required>
  `run_completed` | `run_failed` | `candidate_revealed`
</ParamField>

<ParamField body="valid_signature" type="boolean" default="true">
  `true` (default) | `false`
</ParamField>

Answer: `202 {"event_id": "<uuid>", "event": "...", "run_id": "<uuid>", "valid_signature": true}`. The delivery goes out through the normal webhook queue, usually within a minute, to the URL Vort registered for you, with the envelope you would get in production (`event_id` equals the returned `event_id`). The payload is a fixture: `organization_external_id` is this organization's, `job_external_id` is `cert-webhook`, `run_id` is a fresh id that does not exist on `GET /runs` (answer `2xx` anyway; your handler must not depend on the run existing), `run_completed` carries `progress`, `candidate_revealed` carries `candidate_id: 990000000001`.

<Warning>
  With `"valid_signature": false`, the same event is signed with a deliberately wrong secret. Your endpoint MUST answer non-`2xx` (typically `401`) and MUST NOT process it (C-18). A bad-signature test is sent once and never retried; Vort sees whether you rejected it.
</Warning>

If no webhook is registered for your partner account, the call answers `409 webhook_not_configured`. Ask Vort to register your HTTPS URL and share the signing secret out of band.

## Requirement validation examples

Send these as the body of `POST /runs` to see each code. A `422` creates nothing and costs nothing. All but `location_as_requirement` also work on any fixture job (fixtures have no location). For `location_as_requirement`, first create a job of your own with `"location_text": "Tel Aviv, Israel"`.

Rejections answer `422 requirements_rejected`, with `rejected[]` (and `warnings[]`), each item carrying the requirement `index`, the `code`, `message_he` / `message_en` and `suggestion_he` / `suggestion_en`. Show the message and suggestion verbatim under the requirement at `index` (C-05). Warnings answer `202` with `warnings[]` of the same shape; the run starts (C-06).

### Rejections

<AccordionGroup>
  <Accordion title="empty_text (index 1)">
    ```json theme={null}
    {"job_external_id":"cert-empty","acting_user_external_id":"u-88121","target_count":5,
     "requirements":[{"text":"Kubernetes in production","kind":"must"},{"text":"   ","kind":"nice"}]}
    ```
  </Accordion>

  <Accordion title="invalid_kind (index 0)">
    `kind` must be `must` or `nice`. A `requirements` value that is not an array is also `invalid_kind`, at index `-1`.

    ```json theme={null}
    {"job_external_id":"cert-empty","acting_user_external_id":"u-88121","target_count":5,
     "requirements":[{"text":"Kubernetes in production","kind":"required"}]}
    ```
  </Accordion>

  <Accordion title="too_many_musts (index 3)">
    At most 3 musts.

    ```json theme={null}
    {"job_external_id":"cert-empty","acting_user_external_id":"u-88121","target_count":5,
     "requirements":[{"text":"Kubernetes in production","kind":"must"},{"text":"Team leadership experience","kind":"must"},{"text":"Python","kind":"must"},{"text":"PostgreSQL","kind":"must"}]}
    ```

    On a run of your own job, the job title counts as one must (Vort adds it as the last requirement unless one of yours is the title), so three musts of your own are refused too, at the third, with the message "The job title counts as one must, so at most 2 more musts are possible (3 in total)."
  </Accordion>

  <Accordion title="duplicate (index 1)">
    Same text after trimming and case-folding, whatever the kind.

    ```json theme={null}
    {"job_external_id":"cert-empty","acting_user_external_id":"u-88121","target_count":5,
     "requirements":[{"text":"Kubernetes in production","kind":"must"},{"text":"kubernetes  in production","kind":"nice"}]}
    ```
  </Accordion>

  <Accordion title="too_long (index 0)">
    Over 300 characters.

    ```json theme={null}
    {"job_external_id":"cert-empty","acting_user_external_id":"u-88121","target_count":5,
     "requirements":[{"text":"We are looking for an experienced backend engineer who has worked with Kubernetes in production, led a team of engineers, designed distributed systems at scale, owned on-call rotations, mentored junior developers, written technical design documents, and collaborated closely with product managers across several time zones.","kind":"must"}]}
    ```
  </Accordion>

  <Accordion title="location_as_requirement (index 0)">
    The requirement is a place that is part of the job's `location_text`. Needs a job of your own. First `POST /jobs`:

    ```json theme={null}
    {"external_id":"job-sandbox-tlv","title":"Backend Engineer","description_text":"Backend engineer for our platform team.","location_text":"Tel Aviv, Israel"}
    ```

    then `POST /runs`:

    ```json theme={null}
    {"job_external_id":"job-sandbox-tlv","acting_user_external_id":"u-88121","target_count":5,
     "requirements":[{"text":"Tel Aviv","kind":"must"},{"text":"Kubernetes in production","kind":"must"}]}
    ```
  </Accordion>

  <Accordion title="All rejection codes in one request">
    On the job above; indexes 1 `empty_text`, 2 `invalid_kind`, 3 `duplicate`, 4 `too_long`, 5 `location_as_requirement`, 7 `too_many_musts`:

    ```json theme={null}
    {"job_external_id":"job-sandbox-tlv","acting_user_external_id":"u-88121","target_count":5,
     "requirements":[
      {"text":"Kubernetes in production","kind":"must"},
      {"text":"   ","kind":"must"},
      {"text":"Python","kind":"required"},
      {"text":"kubernetes  in production","kind":"nice"},
      {"text":"We are looking for an experienced backend engineer who has worked with Kubernetes in production, led a team of engineers, designed distributed systems at scale, owned on-call rotations, mentored junior developers, written technical design documents, and collaborated closely with product managers across several time zones.","kind":"must"},
      {"text":"Tel Aviv","kind":"nice"},
      {"text":"Team leadership experience","kind":"must"},
      {"text":"PostgreSQL","kind":"must"}]}
    ```
  </Accordion>
</AccordionGroup>

### Warnings (the run starts)

<AccordionGroup>
  <Accordion title="short_term_may_be_dropped (index 0)">
    A term shorter than 3 characters.

    ```json theme={null}
    {"job_external_id":"cert-webhook","acting_user_external_id":"u-88121","target_count":5,
     "requirements":[{"text":"BI","kind":"nice"},{"text":"Kubernetes in production","kind":"must"}]}
    ```
  </Accordion>

  <Accordion title="years_band_ceiling (index 0)">
    A bounded years range that can act as a ceiling.

    ```json theme={null}
    {"job_external_id":"cert-webhook","acting_user_external_id":"u-88121","target_count":5,
     "requirements":[{"text":"3-5 years of backend development","kind":"must"}]}
    ```
  </Accordion>

  <Accordion title="unverifiable_must (index 0)">
    A must that profiles almost never state (salary, availability, willingness, clearance, driving licence, soft traits).

    ```json theme={null}
    {"job_external_id":"cert-webhook","acting_user_external_id":"u-88121","target_count":5,
     "requirements":[{"text":"Security clearance","kind":"must"},{"text":"Kubernetes in production","kind":"must"}]}
    ```
  </Accordion>

  <Accordion title="All warning codes in one request">
    Indexes 0, 1, 2; valid on your own job too, where the job title makes it three musts.

    ```json theme={null}
    {"job_external_id":"cert-webhook","acting_user_external_id":"u-88121","target_count":5,
     "requirements":[{"text":"BI","kind":"nice"},{"text":"3-5 years of backend development","kind":"must"},{"text":"Security clearance","kind":"must"}]}
    ```
  </Accordion>
</AccordionGroup>

Warnings on a fixture run are returned on the `202` like on a real run; the fixture's payload still uses its fixed requirement list.

## Sandbox-only codes and headers

Request headers (ignored in production):

| Header | Where | Effect |
| - | - | - |
| `x-vort-sandbox-simulate` | any request | [Simulated error](#simulating-errors) |
| `x-vort-sandbox-reveal-price` | `GET /runs/{run_id}` | [Price override](#reveal-price-override) |

Response header: `x-vort-environment: sandbox` on every response.

Route (answers `404 not_found` in production): `POST /sandbox/webhooks/test`.

Error codes that only the sandbox returns:

| Status | `error` | When |
| - | - | - |
| `422` | `sandbox_limit` | `target_count` above 20. |
| `429` | `sandbox_run_quota` | 20 real runs for this organization in the last 24 hours. Honor `Retry-After`. |
| `422` | `unknown_fixture` | `job_external_id` starts with `cert-` but is not a fixture; `fixtures` lists the valid names. |
| `409` | `webhook_not_configured` | Test webhook requested with no webhook registered. |
| `422` | `invalid_request` | A sandbox header or the test-webhook body has an invalid value. |

Handle them like any unknown code, by HTTP status: your production client never sees them.
