Assessment Lab

Assessment Lab documentation

The Assessment Lab gives you everything you need for CV Intake at Scale:

Read this page end to end. The limits and the delivery guarantees matter.

Contents

  1. Getting started
  2. Authentication and errors
  3. Models and prices
  4. Synchronous model calls
  5. Async jobs
  6. Webhooks
  7. sim-1, the simulated model
  8. The dev dataset
  9. The extraction schema
  10. Runs: how we send you CVs
  11. Posting results
  12. Run metrics, the scale pass and the quality score
  13. Budgets
  14. Your namespace
  15. API reference
  16. Troubleshooting

All times are UTC. All request and response bodies are JSON (UTF-8) unless stated otherwise.

Getting started

  1. Sign up at https://lab.uptal.com/signup with the access code from your invitation. You get:
    • an API key (lab_...). It is shown once: store it safely. You use it for every API call and to log in to the portal. You can generate a new one from the dashboard (the old one stops working at once).
    • a webhook signing secret (whsec_...), always visible on your dashboard. We sign every webhook and every run delivery we send you with it.
    • a slug such as c4k9x2mq: your Lab username. It is random and identifies your environment. Your namespace is cand-<slug> and your public hostname is https://<slug>.lab.uptal.com.
  2. Download your kubeconfig from the dashboard (or GET /v1/me/kubeconfig) and check it works: kubectl --kubeconfig lab.kubeconfig get pods.
  3. Deploy your service to your namespace and expose it on https://<slug>.lab.uptal.com with an Ingress.
  4. Register your ingest URL, for example https://<slug>.lab.uptal.com/api/cv-jobs (dashboard, or PUT /v1/me/ingest-url).
  5. Start a practice run from the dashboard, look at the metrics, improve, repeat.
  6. When you are ready, start the Final Run. It is one shot.

Base URLs of the gateway:

From Base URL
Your laptop, CI, anywhere on the Internet https://lab.uptal.com
Pods in your namespace http://lab-gateway.assessment-system.svc.cluster.local (or https://lab.uptal.com)

Everything you can do on the dashboard is also available as a JSON API (see API reference).

Authentication and errors

Send your API key on every request:

Authorization: Bearer lab_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

x-api-key: lab_... is accepted too, so the official Anthropic SDKs work with only a base-URL change.

Errors produced by the gateway have this shape:

{"error": "rate_limited", "message": "Too many job submissions (limit 30/s).", "request_id": "req_6f1c..."}

Every response carries an X-Request-Id header. Errors returned by a real model provider (for example an invalid request body) are passed through in the provider's own format with the provider's status code.

Status error Meaning
400 invalid_json, invalid_request, unsupported_endpoint, streaming_not_supported, unsupported_parameter, unsupported_tool, invalid_callback_url, invalid_ingest_url, confirmation_required Fix the request.
401 unauthorized Missing or wrong API key.
402 budget_exhausted No budget left for this request (see Budgets).
403 suspended, invalid_signature, run_not_active Key suspended, or a file link that is invalid, expired or no longer active.
404 not_found, model_not_found, run_not_found, unknown_cv_id
409 already_registered, run_in_progress, final_already_used, final_in_progress, namespace_not_ready, run_not_started, not_delivered
413 payload_too_large Request bodies are limited to 20 MB (async jobs for real models: 15 MB; results: 64 KB).
429 rate_limited, concurrency_limit, too_many_open_jobs, daily_limit, model_rate_limited Slow down. Always honour the Retry-After header (seconds). model_rate_limited: a real model's shared throughput limit was reached (see Models and prices).
500 internal_error Retry with backoff.
502 / 503 / 504 upstream_unavailable, model_overloaded, upstream_timeout The model failed or is overloaded. Retry with backoff.

Models and prices

GET /v1/models returns the live list. Prices are in USD per 1 million tokens and are what your budget is charged.

Model Endpoints Input Price input / cached input / output
sim-1 all three anything (ignored) free (see sim-1)
astra /v1/chat/completions, /v1/responses text, images, PDF 10.00 / 1.00 / 50.00
sol /v1/chat/completions, /v1/responses text, images, PDF 2.00 / 0.20 / 10.00
terra /v1/chat/completions, /v1/responses text, images, PDF 2.00 / 0.20 / 12.00
luna /v1/chat/completions, /v1/responses text, images, PDF 0.10 / 0.01 / 0.50
mini /v1/chat/completions, /v1/responses text, images, PDF 0.25 / 0.025 / 2.00
mini-4.1 /v1/chat/completions, /v1/responses text, images, PDF 0.40 / 0.10 / 1.60
opus /v1/messages text, images, PDF 5.00 / 0.50 / 25.00 (cache write 6.25)
fable /v1/messages text, images, PDF 10.00 / 1.00 / 50.00 (cache write 12.50)

astra, sol, terra, luna, mini and mini-4.1 are OpenAI models (several of them reasoning models: their reasoning tokens are billed as output). opus and fable are Anthropic Claude models. Requests with more than 272,000 input tokens on the OpenAI models are billed at 2x input and 1.5x output.

Throughput limits. Every real model has a throughput limit (tokens per minute) that is shared by all candidates using the Lab at the same time. When it is reached the gateway answers 429 with "error": "model_rate_limited" and a Retry-After header (an async job fails with error.code: "model_rate_limited" and error.retry_after, in seconds). It is not charged. Retry after the delay, with jitter, and spread your live calls instead of sending them all at once. sim-1 has no such limit.

Synchronous model calls

Endpoint Compatible with
POST /v1/chat/completions OpenAI Chat Completions
POST /v1/responses OpenAI Responses
POST /v1/messages Anthropic Messages

The request and response bodies are those of the original APIs; set model to one of the names above. The gateway holds the connection until the model answers (up to 300 seconds for real models).

Attribute every call to a CV. Put the CV's id in metadata.cv_id of the request body (or send the header X-Lab-Cv-Id: <cv_id>). We use it to attribute model calls and cost to CVs in your run metrics, to bill Final Run calls to your eval budget, and sim-1 needs it to know which CV you are asking about. The gateway removes metadata before forwarding (for /v1/messages it keeps only metadata.user_id).

Documents. There is no file upload API: send documents inline.

DOCX is not accepted as a file input by any model: convert it to text (or images) yourself.

Restrictions.

Example:

curl https://lab.uptal.com/v1/responses \
  -H "Authorization: Bearer $LAB_API_KEY" -H "Content-Type: application/json" \
  -d '{"model": "luna", "input": "Say hello", "metadata": {"cv_id": "cv_0001"}}'

Async jobs

Submit any model request as a job and get the result by webhook, by polling, or both.

Submit: POST /v1/jobs

{
  "endpoint": "/v1/responses",
  "body": { "model": "sim-1", "input": "..." },
  "callback_url": "https://<slug>.lab.uptal.com/api/lab-webhooks",
  "metadata": { "cv_id": "sim-000123", "anything": "you like" }
}

Response 202:

{"id": "job_9b1f...", "object": "job", "status": "queued", "created_at": "2026-10-14T09:00:00.000000Z"}

Limits per API key:

Poll: GET /v1/jobs/{id}

Rate limit: 20 requests per second per key.

{
  "id": "job_9b1f...",
  "object": "job",
  "status": "succeeded",
  "model": "sim-1",
  "endpoint": "/v1/responses",
  "result": { "...": "the response body the synchronous endpoint would have returned" },
  "error": null,
  "metadata": { "cv_id": "sim-000123" },
  "created_at": "2026-10-14T09:00:00.000000Z",
  "completed_at": "2026-10-14T09:00:21.400000Z"
}

status is queued, running, succeeded or failed. A finished job's result is kept for 24 hours; after that the job still exists but result is null. succeeded means the model answered: you still have to check the answer itself (finish reason, valid JSON). When failed, error is an object:

error.code Meaning Retry?
model_overloaded Transient overload. Yes, submit a new job (with backoff).
model_rate_limited The model's shared throughput limit was reached. Yes, submit a new job after error.retry_after seconds.
upstream_error The model provider failed. Yes, with backoff.
invalid_request The provider rejected the request (error.details has its answer). No, fix the request.
budget_exhausted No budget left. No.

Webhooks

When a job finishes and it has a callback_url, we POST:

{"id": "job_9b1f...", "status": "succeeded", "result": {"...": "..."}, "error": null, "metadata": {"cv_id": "sim-000123"}}

with the header

X-Lab-Signature: t=1791975123,v1=5f2c...e9

where v1 = hex(HMAC_SHA256(webhook_secret, t + "." + raw_request_body)). Verify it against the raw body (before any JSON parsing), compare in constant time, and reject timestamps that are too old (we recommend 5 minutes). PHP:

[$t, $v1] = [null, null];
foreach (explode(',', $request->header('X-Lab-Signature', '')) as $part) {
    [$k, $v] = array_pad(explode('=', $part, 2), 2, null);
    if ($k === 't') { $t = $v; } elseif ($k === 'v1') { $v1 = $v; }
}
$expected = hash_hmac('sha256', $t . '.' . $request->getContent(), $webhookSecret);
$valid = $t !== null && $v1 !== null && hash_equals($expected, $v1) && abs(time() - (int) $t) <= 300;

Delivery guarantees. Design for all of these:

The same secret and header format are used for the run deliveries we send to your ingest URL.

sim-1, the simulated model

sim-1 is free and behaves like a slow, imperfect production model. Use it for load and for everything you want to test without spending budget.

The dev dataset

The files are real CVs from our production traffic with personal details replaced by fake ones: PDFs (text, designed, scanned), images, Word files, English, Arabic and bilingual. Some uploads are not CVs at all. The hidden holdout set used for scoring is drawn from the same population.

Treat as_of as 2026-10-01 for every computation that depends on "today" (the run deliveries tell you as_of too).

The extraction schema

This is exactly what you must return in data for each CV:

{
  "is_cv": true,
  "document_language": "en",            // "en" | "ar" | "mixed" | "other"
  "full_name": "string|null",           // as written (original script)
  "email": "string|null",
  "phone": "string|null",               // E.164 e.g. "+966500001234"; first/primary number
  "location": { "city": "string|null", "country_code": "string|null" },   // current residence; ISO 3166-1 alpha-2
  "nationality_code": "string|null",    // ISO alpha-2, ONLY if explicitly stated
  "current_title": "string|null",       // title of the ongoing role (is_current) — null if no role is ongoing
  "total_experience_months": "int|null",// professional experience up to `as_of` (2026-10-01 for gold), overlapping periods counted once; year-only start = January, year-only end = December
  "experience": [ { "title": "string", "company": "string|null", "start": "YYYY-MM|YYYY|null", "end": "YYYY-MM|YYYY|null", "is_current": false } ],
  "education": [ { "degree": "string|null", "field": "string|null", "institution": "string|null", "end_year": "int|null" } ],
  "skills": ["string"],
  "languages": ["string"]                // spoken languages, English names, e.g. "Arabic"
}

Rules: never guess (null when absent); values as written in the CV (don't translate) except normalised fields (codes, dates, phone, languages); Hijri dates → Gregorian; when is_cv is false every other field is null / empty (including document_language; the scorer counts a non-null value on a non-CV as a hallucination).

Runs: how we send you CVs

Your ingest URL

Register the URL where we deliver CV jobs: dashboard, or

curl -X PUT https://lab.uptal.com/v1/me/ingest-url -H "Authorization: Bearer $LAB_API_KEY" \
  -H "Content-Type: application/json" -d '{"url": "https://<slug>.lab.uptal.com/api/cv-jobs"}'

It must be https://<slug>.lab.uptal.com/<any path> (your own host, port 443).

Deliveries

For every CV in a run we send:

POST <your ingest URL>
Content-Type: application/json
X-Lab-Signature: t=<unix>,v1=<hex hmac>

{
  "run_id": "run_8c2e...",
  "cv_id": "sim-000123",
  "mode": "sim",
  "file_url": "https://lab.uptal.com/v1/files/sim-000123?run=run_8c2e...&exp=...&sig=...",
  "mime_type": "application/pdf",
  "as_of": "2026-10-01",
  "submitted_at": "2026-10-14T09:00:00.100000Z"
}

Run types

Run What Mode Rate Deadline Limits
practice-sim 300 jobs (sim-000001 ... sim-000300) sim 10 / s results within 10 min of start 6 per 24 h
practice-live 10 random dev CVs (their real cv_ids) live 1 / s results within 15 min 6 per 24 h, billed to your dev budget
final scale: 6,000 jobs (sim-000001 ... sim-006000) then quality: every holdout CV (opaque cv_ids such as q-3f9a1c0b7d2e) sim, then live 10 / s, then 2 / s scale: 30 min; quality: 45 min one shot, billed to your eval budget

Posting results

POST /v1/results
Authorization: Bearer lab_...

{"cv_id": "sim-000123", "run_id": "run_8c2e...", "status": "done", "data": { ...extraction... }, "error": null}

Run metrics, the scale pass and the quality score

GET /v1/runs/{id} (and the dashboard) shows, per run:

Passing the scale run

The scale phase of the Final Run (and each practice-sim, with its own deadline) passes when all of these hold:

  1. at least 99.5% of the CVs have their first result within 30 minutes of the run start, each backed by a model call that carried that cv_id and completed after we delivered the CV and before you posted its result;
  2. zero duplicate results;
  3. at most 1% of the results have status failed.

The quality score

Live runs (practice-live and the quality phase of the Final Run) are scored against gold labels, 0 to 100:

Field Weight Correct when
is_cv 10 equal. If the document is not a CV and you say it is, the whole document scores 0
full_name 8 equal after normalisation (case, spacing, Arabic letter variants, diacritics)
email 6 equal (case-insensitive)
phone 6 equal in E.164
location.city 3 same city
location.country_code 3 equal
nationality_code 3 equal
current_title 6 same title
total_experience_months 8 within max(6 months, 10%)
experience 20 entries matched one-to-one; each matched entry scored on title, company, start, end, is_current; F1 over entries
education 10 same approach (degree, field, institution, end year)
skills 7 F1 with semantic matching
languages 4 F1
document_language 6 equal

Budgets

Bucket Default Used for
dev $40 everything, including practice-live
eval $15 calls whose metadata.cv_id belongs to your running Final Run (automatic); when it is spent, those calls draw from dev

Your namespace

Your kubeconfig points at https://k8s.lab.uptal.com:6443 with namespace cand-<slug> and gives you a namespace-scoped identity.

What you can manage: Deployments, StatefulSets, Pods (incl. logs, exec, port-forward), Services (ClusterIP), ConfigMaps, Secrets, PersistentVolumeClaims, Ingresses, Jobs, CronJobs and HorizontalPodAutoscalers. You can read Events, your ResourceQuota, LimitRange and NetworkPolicies, and use kubectl top pods.

What you cannot do: change NetworkPolicies, quotas, RBAC or ServiceAccounts; create Endpoints, ExternalName Services, externalIPs, NodePort or LoadBalancer Services, or Traefik custom resources; use priority classes; run privileged pods or mount host paths (Pod Security Standard baseline is enforced; NET_RAW is dropped).

Quota (whole namespace). The cluster is shared, so the quota is small: design for it.

Resource Limit
CPU requests / limits (sum of all containers) 500m / 2 cores
Memory requests / limits (sum of all containers) 1536 MiB / 3 GiB
Ephemeral storage requests / limits 10 GiB / 20 GiB
Pods 30
PersistentVolumeClaims 4, 20 GiB in total (local-path, ReadWriteOnce, max 20 GiB each)
Services 20 (no NodePort, no LoadBalancer)
Ingresses 10
ConfigMaps / Secrets 100 / 100
Jobs / CronJobs 100 / 10

Per-container defaults and maximum (LimitRange). A container without explicit resources gets a request of 50m CPU / 96 MiB and a limit of 500m CPU / 512 MiB. No container may exceed 2 CPU / 3 GiB.

Set explicit requests and limits on every container. With the default limits only 4 containers fit in the namespace (4 x 500m = the 2-core CPU limit), and a pod that does not fit the quota is rejected at creation (you see it in kubectl get events / kubectl describe replicaset, not as a Pending pod). For example, give small workers requests: {cpu: 50m, memory: 128Mi} and limits: {cpu: 200m, memory: 256Mi} so that ten of them fit. The work is I/O-bound (waiting on the model), so keep worker counts and per-process concurrency sensible rather than adding pods. kubectl describe quota shows what you use.

Network:

Public HTTPS: one host per candidate. Expose your service on exactly <slug>.lab.uptal.com. A certificate is issued automatically by Let's Encrypt within a minute or two. Example:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: intake
  annotations:
    traefik.ingress.kubernetes.io/router.tls.certresolver: le
spec:
  rules:
    - host: <slug>.lab.uptal.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: intake
                port:
                  number: 80
  tls:
    - hosts: [<slug>.lab.uptal.com]

Every rule's host (and every tls.hosts entry) must be exactly your host. Do not set tls.secretName or a defaultBackend. Route different parts of your app (API, dashboard) by path on that one host.

Images: your pods always re-pull their image when they start (imagePullPolicy is forced to Always; the node only checks the tag with the registry and keeps already-downloaded layers cached). So every image must come from a registry the cluster can reach: a public image (Docker Hub, GitHub Container Registry, ...), or your own private registry with a pull secret you create in your namespace (kubectl create secret docker-registry regcred --docker-server=... --docker-username=... --docker-password=...) and reference with imagePullSecrets. Images that exist only on your laptop cannot be used. Image pulls are done by the node, so the outbound network rules above do not apply to them. The node is linux/amd64: build for that platform.

API reference

All endpoints are under https://lab.uptal.com and need Authorization: Bearer lab_... unless marked public.

Method and path Purpose
POST /v1/signup (public) {"full_name", "email", "access_code"} → your account incl. api_key (once)
GET /v1/me Your account: slug, host, namespace, ingest URL, webhook secret, budgets
PUT /v1/me/ingest-url {"url": "https://<slug>.lab.uptal.com/..."}
POST /v1/me/api-key/rotate New API key (old one stops working)
POST /v1/me/webhook-secret/rotate New webhook signing secret
GET /v1/me/kubeconfig Your kubeconfig (YAML)
GET /v1/budget Budget buckets
GET /v1/models Models, endpoints, input types, prices
POST /v1/chat/completions OpenAI Chat Completions (sync)
POST /v1/responses OpenAI Responses (sync)
POST /v1/messages Anthropic Messages (sync)
POST /v1/jobs Submit an async job
GET /v1/jobs/{id} Poll a job
GET /v1/dataset/dev Dev manifest
GET /v1/dataset/dev/labels/{cv_id} Reference labels of a labelled dev CV
GET /v1/files/{cv_id} Download a dev file (API key) or a run file (signed link, no key)
POST /v1/runs Start a run: {"type": "practice-sim" | "practice-live"} or {"type": "final", "confirm": true}
GET /v1/runs Your runs
GET /v1/runs/{id} Run status, queue position, metrics, quality score
POST /v1/runs/{id}/cancel Cancel a practice run
POST /v1/results Post a result

The documentation is also available as Markdown at https://lab.uptal.com/docs.md.

Troubleshooting

Download as Markdown