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 the org’s candidates with GET /v1/candidates?page=1&limit=25. 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.
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, and /admin routes, whatever their scopes. A
leaked key can never mint further credentials 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.