Open appvitae.ai

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 returns 404. Call the same path without the public-api segment: /v1/public-api/candidates becomes /v1/candidates, /v1/public-api/webhook-subscriptions becomes /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

  1. Mint a key in Settings and copy the plaintext.

  2. Create the candidate. fullName is 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"]
  }'
  1. Upload the resume as a multipart file field. PDF or DOCX only (sniffed from the file bytes, not the declared content type), 10MB max. The upload returns immediately with "processing": true and extraction continues asynchronously. Parsing consumes one AI credit; an organization with no credits remaining gets 402 and 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"
  1. 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.

FieldTypeNotes
fullNamestringRequired. 2 to 50 characters.
emailstringValid email address, max 100 characters.
phoneNumberstringMax 50 characters.
linkedinUrlstringMax 300 characters. Lowercase u in Url. Accepted on create and update.
network1stringSame LinkedIn URL. Accepted on create and update. GET returns this field.
biostringMax 2000 characters.
locationstringFree-text location, max 160 characters.
citystringMax 120 characters.
statestringMax 120 characters.
countrystringMax 120 characters.
skillsarray of stringsMust be a JSON array, not a comma-separated string.
otherLanguagesarray of objectsEach entry is { "language": "Spanish", "fluency": "Advanced" }.
minSalaryintegerUp to 6 digits. Requires maxSalary and salaryCurrency.
maxSalaryintegerUp to 7 digits. Must be greater than minSalary.
salaryCurrencystringRequired whenever either salary value is sent.
availabilityStatusstringOne of OPEN, NOT_OPEN, DO_NOT_CONTACT.
customFieldsobjectValues for fields your organization has defined. See Custom fields.
profileobjectStructured profile attributes.
experienceobjectStructured work history.
educationobjectStructured 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, and DELETE on /custom-fields are admin-only, and a key always acts with its owner’s role. A key minted with scopes also needs custom_fields_write; custom_fields_read alone is refused with This 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.

ScopeCovers
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_write lets a key create and update candidates, but GET /v1/candidates and GET /v1/candidates/:id are still refused with 403.

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:

HeaderMeaning
X-RateLimit-LimitRequests allowed per minute for this key
X-RateLimit-RemainingRequests left in the current window
X-RateLimit-ResetUnix timestamp (seconds) when the next slot frees up
Retry-AfterSeconds 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:

StatusWhen
401 UnauthorizedMissing, malformed, revoked, or expired key
403 ForbiddenKey lacks the scope for the route, or the route is session-only
429 Too Many RequestsPer-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 expiresAt on keys issued to short-lived integrations.
  • Rotate on personnel or vendor changes; revoke on suspected compromise.