The Vitae API
There is one Vitae API. The routes your integration calls are the same routes the Vitae app itself uses, served from the same place, returning the same response shapes. A personal API key turns any of those routes into a server-to-server call: the key belongs to the user who minted it and authenticates as that user, with the same role, permissions, and organization as their session.
Base URL: https://api.vitae.ai/v1
Migrating from
/v1/public-api/...? The separate public API namespace has been removed and now returns404. Call the same path without thepublic-apisegment:/v1/public-api/candidatesbecomes/v1/candidates,/v1/public-api/webhook-subscriptionsbecomes/v1/webhook-subscriptions, and so on. Existing org keys were migrated to their creator as owner; keys without an identifiable owner were revoked and must be re-minted.
Authentication
Any org member can mint their own keys from Settings. The plaintext key is shown exactly once at creation; only a hash and a short prefix are stored. Present the key as a bearer token:
curl https://api.vitae.ai/v1/candidates/cand_123 \
-H "Authorization: Bearer vit_3f9c1e7a2b8d4f60_..."
An X-API-Key header is also accepted for clients that reserve Authorization
for other purposes:
curl https://api.vitae.ai/v1/candidates/cand_123 \
-H "X-API-Key: vit_3f9c1e7a2b8d4f60_..."
Keys have the shape vit_<prefix>_<secret>. A request authenticated by your key
behaves exactly like your own session: same role, same permissions, same
organization, same response shapes the app receives. A key cannot escalate
beyond its owner, and it stops working when the owner is deactivated or leaves
the organization.
Quick start: create a candidate with a resume
-
Mint a key in Settings and copy the plaintext.
-
Create the candidate.
fullNameis the only required field (2 to 50 characters). Every other field is optional, and the full accepted set is in Candidate fields below.
curl -X POST https://api.vitae.ai/v1/candidates \
-H "Authorization: Bearer vit_3f9c1e7a2b8d4f60_..." \
-H "Content-Type: application/json" \
-d '{
"fullName": "Ada Lovelace",
"email": "ada@example.com",
"skills": ["analytical-engines", "mathematics"]
}'
- Upload the resume as a multipart
filefield. PDF or DOCX only (sniffed from the file bytes, not the declared content type), 10MB max. The upload returns immediately with"processing": trueand extraction continues asynchronously. Parsing consumes one AI credit; an organization with no credits remaining gets402and the parse is not queued.
curl -X POST https://api.vitae.ai/v1/candidates/cand_123/resume \
-H "Authorization: Bearer vit_3f9c1e7a2b8d4f60_..." \
-F "file=@resume.pdf"
- Read the candidate back. Once parsing finishes, the extracted profile shows up here, in the same shape the Vitae app renders.
curl https://api.vitae.ai/v1/candidates/cand_123 \
-H "Authorization: Bearer vit_3f9c1e7a2b8d4f60_..."
Candidate fields
POST /v1/candidates and PATCH /v1/candidates/:id accept the fields below.
Anything not on this list is rejected with VALIDATION_FAILED and the message
property <name> should not exist, so field names are worth getting exactly
right. They are case-sensitive: linkedinUrl is accepted and linkedinURL
is not.
LinkedIn is the one field whose create and update names differ historically.
On create, send linkedinUrl or network1 (both write the same URL). On
update, send network1 or linkedinUrl. GET always returns the URL on
network1. A CV parse fills network1 automatically; you do not need a CV to
set it.
List candidates without requesting an exact total with
GET /v1/candidates?pagination=cursor&limit=50. The default limit is 50; the maximum
is 100. Rows are newest first, with the candidate id breaking ties. The response
contains meta.cursor:
{ "nextCursor": "opaque-continuation-token", "hasNextPage": true, "limit": 50 }
Pass that token as cursor on the next request, keeping the same filters:
GET /v1/candidates?pagination=cursor&limit=50&cursor=opaque-continuation-token
Stop when nextCursor is null and hasNextPage is false. Tokens belong to the
same user, organization, role and filter set. An invalid, edited or foreign token
returns 400; start again without a cursor. Do not combine page with cursor
pagination. A cursor page does not report an exact total. Cursor mode rejects ai=1 or
sort=relevance with 400 before running AI. Use numbered pages for AI widening
or relevance ordering. A keyword search keeps the existing internal cap of the
best 5,000 matches, then pages that set newest first; this is not an uncapped
keyword export.
The numbered contract remains available at GET /v1/candidates?page=1&limit=25
for clients that need numbered pages and exact totals. Filter with
name, skills, currentPosition, currentCompany, pastCompany, location,
and customFields. For a CEFR custom field such as english_level, a minimum
threshold is:
GET /v1/candidates?customFields=[{"key":"english_level","op":"gte","value":"A2"}]
That includes A2, B1, B2, C1, C2, and Native, and excludes A1.
| Field | Type | Notes |
|---|---|---|
fullName | string | Required. 2 to 50 characters. |
email | string | Valid email address, max 100 characters. |
phoneNumber | string | Max 50 characters. |
linkedinUrl | string | Max 300 characters. Lowercase u in Url. Accepted on create and update. |
network1 | string | Same LinkedIn URL. Accepted on create and update. GET returns this field. |
bio | string | Max 2000 characters. |
location | string | Free-text location, max 160 characters. |
city | string | Max 120 characters. |
state | string | Max 120 characters. |
country | string | Max 120 characters. |
skills | array of strings | Must be a JSON array, not a comma-separated string. |
otherLanguages | array of objects | Each entry is { "language": "Spanish", "fluency": "Advanced" }. |
minSalary | integer | Up to 6 digits. Requires maxSalary and salaryCurrency. |
maxSalary | integer | Up to 7 digits. Must be greater than minSalary. |
salaryCurrency | string | Required whenever either salary value is sent. |
availabilityStatus | string | One of OPEN, NOT_OPEN, DO_NOT_CONTACT. |
customFields | object | Values for fields your organization has defined. See Custom fields. |
profile | object | Structured profile attributes. |
experience | object | Structured work history. |
education | object | Structured education history. |
Language fluency
otherLanguages is a list of objects, not a set of per-language fields. There
is no spanishLevel or englishLevel. The fluency values the Vitae app
offers are Basic, Conversational, and Advanced.
{
"fullName": "Ada Lovelace",
"otherLanguages": [
{ "language": "Spanish", "fluency": "Advanced" },
{ "language": "English", "fluency": "Conversational" }
]
}
Salary
The salary fields are a set of three. Send minSalary, maxSalary, and
salaryCurrency together or send none of them. maxSalary must be strictly
greater than minSalary. There is no separate freelance or day-rate field: a
contract rate goes in the same three fields, in whatever unit your currency and
range imply.
{
"fullName": "Ada Lovelace",
"minSalary": 60000,
"maxSalary": 90000,
"salaryCurrency": "EUR"
}
Location
Send structured city, state, and country rather than one concatenated
string wherever you can. Vitae resolves a candidate’s geography by preferring
the structured fields, and only falls back to parsing the free-text location
string when they are absent. Structured values geocode more reliably, which is
what distance filtering depends on.
{
"fullName": "Ada Lovelace",
"city": "Valencia",
"state": "Valencian Community",
"country": "Spain"
}
You can send location as well. It is stored and displayed, but it is the
weaker signal of the two.
Custom fields
Anything Vitae does not model natively is a custom field. Your organization
defines them once, and then their values ride along inside the customFields
object on a candidate. This is how you capture things like a source, a work
mode, or a professional status that has no built-in field.
Definitions are managed over the API:
GET /v1/custom-fields List definitions (optional ?entity=candidate)
POST /v1/custom-fields Create a definition
PATCH /v1/custom-fields/:id Update a definition
DELETE /v1/custom-fields/:id Archive a definition
Create one by naming the entity it belongs to, a label, and a type:
curl -X POST https://api.vitae.ai/v1/custom-fields -H "Authorization: Bearer vit_3f9c1e7a2b8d4f60_..." -H "Content-Type: application/json" -d '{
"entity": "candidate",
"label": "Professional status",
"type": "select",
"options": ["Employed", "Freelance", "Looking"]
}'
entity is candidate, job, or contact. type is one of text,
textarea, number, phone, url, email, date, checkbox, select, or
multiselect. A select or multiselect also takes options (up to 50).
required, helpText, and sortOrder are optional.
The response returns the definition’s key, which is what you write values
against. The key is derived from the label by lowercasing it and replacing each
run of non-alphanumeric characters with an underscore, so Professional status
becomes professional_status. You can also set key yourself at creation.
Then send values under customFields, keyed by that key:
curl -X POST https://api.vitae.ai/v1/candidates -H "Authorization: Bearer vit_3f9c1e7a2b8d4f60_..." -H "Content-Type: application/json" -d '{
"fullName": "Ada Lovelace",
"customFields": {
"professional_status": "Freelance",
"work_mode": "Remote"
}
}'
A key with no matching definition is rejected with
Unknown custom field "<key>", so create the definition before you send values
for it.
Creating definitions needs an org admin’s key. Reading definitions is open to any member, but
POST,PATCH, andDELETEon/custom-fieldsare admin-only, and a key always acts with its owner’s role. A key minted with scopes also needscustom_fields_write;custom_fields_readalone is refused withThis API key is missing the custom_fields_write scope for this route. An unscoped key belonging to an org admin needs no extra scope.
Screening results
An outside screening tool (a voice-AI interview, for example) can store its result on an application: an overall score, an optional outcome, a breakdown by competency, a summary, and the transcript and recording. Results belong to one application, so the same candidate can carry a different score for each role.
PUT /v1/applications/:applicationId/screening-results Save a result
GET /v1/applications/:applicationId/screening-results List results, newest first
DELETE /v1/applications/:applicationId/screening-results/:resultId Delete a result
Read the application id from the job’s applicants list
(GET /v1/jobs/:id/applicants). Saving needs applications_write, listing
needs applications_read, and deleting needs applications_write. An
application in another organization answers 404.
curl -X PUT https://api.vitae.ai/v1/applications/cmu2hjrqi00aa01qm6lnyhlp2/screening-results \
-H "Authorization: Bearer vit_3f9c1e7a2b8d4f60_..." \
-H "Content-Type: application/json" \
-d '{
"source": "HRMless",
"externalId": "scr_123",
"score": 82,
"scaleMax": 100,
"outcome": "recommended",
"competencies": [
{ "name": "Communication", "score": 90, "note": "Clear, structured answers" },
{ "name": "Problem solving", "score": 74 }
],
"summary": "Strong communicator, light on scheduling software.",
"transcriptUrl": "https://hrmless.example.com/t/scr_123",
"recordingUrl": "https://hrmless.example.com/r/scr_123.mp3",
"screenedAt": "2026-10-20T09:30:00Z"
}'
| Field | Required | Notes |
|---|---|---|
source | Yes | Your tool’s name, up to 100 characters |
externalId | Yes | Your id for this screening, up to 200 characters |
score | Yes | From 0 to scaleMax |
scaleMax | No | The top of the scale. Defaults to 100 |
outcome | No | recommended, maybe, or not_recommended |
competencies | No | Up to 50 of { name, score, note? }, scored on the same scale |
summary | No | Up to 10,000 characters |
transcript | No | The transcript text, up to 100,000 characters |
transcriptUrl | No | An https link to the transcript |
recordingUrl | No | An https link to the recording. Vitae stores the link, never the file |
screenedAt | Yes | When the screening took place, ISO-8601 |
Sending the same source and externalId for the same application again
replaces that result and answers 200; a new pair answers 201. A replaced
result keeps only what the latest request sent, so a field you leave out is
cleared. An application can hold several results (one per source and external
id). Job board applicant rows carry the latest by screenedAt as screeningSummary.
Links must be https. Anything else is refused with 400 and a message naming
the field, for example recordingUrl must be an https link.
Screening results are for your recruiters. They are never shown in the client
portal unless that application’s fieldMaskOverrides.screeningResults is set
to false when the candidate is put forward. Erasing a candidate’s personal
data deletes their screening results. Each save also fires the
application_screening_result_saved
webhook.
Scopes
Scopes are optional. A key minted with no scopes has its owner’s full
access, on every route the owner’s session can reach. A key minted with
scopes is a restricted key: it can only touch the resources its scopes name,
and any route outside the scope map is refused. Read scopes cover GET
requests; write scopes cover mutations.
| Scope | Covers |
|---|---|
candidates_read / candidates_write | /candidates |
jobs_read / jobs_write | /jobs |
applications_read / applications_write | /applications |
placements_read / placements_write | /placements (fee and billing marks stay session-only; see below) |
tasks_read / tasks_write | /tasks |
activities_read | /activities (read-only) |
inbox_read / inbox_write | /inbox |
custom_fields_read / custom_fields_write | /custom-fields (see Custom fields) |
contacts_read / contacts_write | /contacts |
campaigns_read / campaigns_write | /campaigns |
prospecting_read / prospecting_write | /prospecting |
webhooks_read / webhooks_write | /webhook-subscriptions |
clients_read / clients_write | /clients |
notes_read / notes_write | /notes |
workflows_read / workflows_write | /workflows |
documents_read / documents_write | /documents |
sourcing_read / sourcing_write | /searches and /pools (the sourcing hub) |
Two limits apply on top of the table. placements_write covers the placement
record (create, update, close, dismiss a missing hire) but not /fee or
/billing routes, which change what a customer is billed and need a session or
an unscoped key. And every resource not in the table (for example
/agent, /client-portal, /job-rubrics, /emails, /skills,
/notifications) is refused for scoped keys with
No API key scope grants this request; reach those with a key minted without
scope restrictions.
The atomic campaign contact import
changes both resources, so restricted keys need both contacts_write and
campaigns_write. Prospecting automation follows a separate
search lifecycle.
Use scoped keys for third-party integrations that only need a slice of the API, and unscoped keys where you want full user parity.
A write scope does not include read access.
candidates_writelets a key create and update candidates, butGET /v1/candidatesandGET /v1/candidates/:idare still refused with403.This is the most common way a key ends up unable to do its job. Most integrations look a record up before they write to it, to find the candidate they are attaching a CV to, or to avoid creating a duplicate. A write-only key fails on that very first lookup, before it ever reaches the operation you built it for.
Grant both scopes for any resource your integration reads, including reads it only performs to decide what to write:
candidates_read candidates_write
A refused request names the scope it needed, so a 403 reading
This API key is missing the candidates_read scope for this route means the key
is under-scoped, not that the URL is wrong.
Scopes are fixed at creation. There is no endpoint that edits them, and rotating a key only replaces its secret: a rotated key keeps exactly the scopes it was minted with. To widen an under-scoped key, create a new key with the full scope set, move your integration onto it, then revoke the old one.
Session-only routes
API keys are always refused with 403 on /api-keys, /mcp-access-tokens,
/oauth, /auth, /users, /admin, and /mcp-enterprise-auth routes, and on
the organization member, invite, and workspace OS routes
(/organizations/members, /organizations/invites, /organizations/os),
whatever their scopes. A leaked key can never mint further credentials, invite
or remove members, or rewrite authentication state. Key management requires a
signed-in session.
Rate limiting
Every key has a per-minute budget (default 120, configurable 1 to 10,000 at mint time), enforced in a sliding window shared across all API instances. Reads and writes are tracked in separate windows, each with the key’s full budget. Every key-authenticated response carries:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per minute for this key |
X-RateLimit-Remaining | Requests left in the current window |
X-RateLimit-Reset | Unix timestamp (seconds) when the next slot frees up |
Retry-After | Seconds to wait before retrying (only on 429) |
When the window is exhausted the API returns 429 Too Many Requests. Back off
for Retry-After seconds rather than retrying immediately.
Managing keys
Key management is session-authenticated (a logged-in user, never an API key):
POST /v1/api-keys Mint a key (name, optional scopes, rateLimitPerMinute, expiresAt)
GET /v1/api-keys List your keys (newest first)
GET /v1/api-keys?all=true List every key in the org (org admins only)
POST /v1/api-keys/:id/rotate Rotate a key (new plaintext returned once)
DELETE /v1/api-keys/:id Revoke a key (irreversible)
Members manage their own keys; org admins can also rotate or revoke any key in the organization. A key is rejected when it is revoked, expired, when its owner is deactivated, or when its owner leaves the organization, so offboarding a user cuts their keys automatically.
Errors
The API uses the standard error envelope. Statuses specific to key-authenticated requests:
| Status | When |
|---|---|
401 Unauthorized | Missing, malformed, revoked, or expired key |
403 Forbidden | Key lacks the scope for the route, or the route is session-only |
429 Too Many Requests | Per-key rate limit exceeded; see Retry-After |
Everything else (validation, not-found, business errors) behaves exactly as it does for the app, because the request runs as the key’s owner.
Security
- Always call over HTTPS.
- Treat keys like passwords: never embed them in client-side code or commit them to source control.
- Scope keys given to third parties to the minimum access they need.
- Set an
expiresAton keys issued to short-lived integrations. - Rotate on personnel or vendor changes; revoke on suspected compromise.