On 1st January 2027 Ezekia is permanently removing versions 1 and 2 of its API, along with any requests to /api/ that do not include a version number. Any integration still using them will stop working that day, so it needs to move to version 4 (v4) before 31st December 2026.
This guide is for the developer or technical contact who looks after your integration. It starts with a few checks that apply to every request, then lists each endpoint that changed, was removed or was added between v2 and v4. For most integrations the move is a small change.
API documentation: https://ezekia.com/api/documentation
Questions: email support@ezekia.com
Before you start
These apply to every request you make to the Ezekia API, whichever version you are on. Getting them right first makes the rest of the migration much easier to test.
Always send JSON headers
Include these two headers on every request that sends or expects JSON:
Accept: application/json
Content-Type: application/json
Without Accept: application/json, an error can come back as a full web page (HTML) instead of a readable JSON error message, which makes problems much harder to diagnose.
The one exception is uploading a file, such as a CV or other document. Those requests use Content-Type: multipart/form-data instead. Keep Accept: application/json on them.
Authentication
Every request needs your API token as a bearer token:
Authorization: Bearer YOUR_API_TOKEN
Tokens are created by an administrator in Ezekia under Settings > Integrations > API Tokens. Treat a token like a password.
Rate limits
- The API allows up to 5 requests per second per API user.
- If you go over, you will get a 429 Too Many Requests response. Check the Retry-After header and wait that long before trying again.
- Spread requests out evenly (for example with a short pause between calls) rather than sending them in bursts.
Test before going live
Change your requests from /api/v1/, /api/v2/ or unversioned /api/ to /api/v4/, then test each one rather than assuming it works just because the address changed. If you would like a separate sandbox account to test in, contact support@ezekia.com.
Summary
v1 and v2 of the API are being retired. v4 is the version to move to (v3 also exists but is not a stopping point, so go straight to v4). Most endpoints work the same way in v4 as they did in v2, just under a different URL prefix. A smaller set of endpoints have changed response fields, new or renamed request fields, or different validation rules. Those are listed below with before/after examples. A few endpoints were added along the way that you don't need but may find useful. /categories is separate from all of this. It was never versioned and doesn't need to change.
If you're calling POST /people/{person}/profile-picture: the request and validation are unchanged, but be aware the response currently comes back in an older field shape than the rest of the v4 people endpoints. See "What to do" under People below.
Endpoints with changes
People
GET/POST/PUT/DELETE /people, POST /people/{person}/copy
What changed: Several fields were added, a few were removed, and some were renamed. The owner field is now a wrapper object instead of a plain person record. On create, linking a new person to a project changed from a single assignment field to a list of projects.
Before (v2)
{
"id": 501,
"firstName": "Jamie",
"lastName": "Ostrowski",
"owner": {
"id": 12,
"fullname": "Alex Rivera"
},
"relationships": {
"assignments": [{ "id": 88, "name": "VP Engineering Search" }],
"opportunities": [{ "id": 41, "name": "Board Advisory" }],
"candidatesLists": [{ "id": 9, "name": "2024 Shortlist" }]
}
}
After (v4)
{
"id": 501,
"firstName": "Jamie",
"lastName": "Ostrowski",
"owner": {
"type": "candidate",
"isPrimary": true,
"model": { "id": 12, "fullname": "Alex Rivera" },
"tags": []
},
"owners": [
{
"type": "candidate",
"isPrimary": true,
"model": { "id": 12, "fullname": "Alex Rivera" },
"tags": []
}
],
"aliases": ["Jamie O."],
"currentStatusReduced": { "latestIndustry": "Software", "latestLocation": "London" },
"currentStatusPositionType": "permanent",
"currentStatusCareerStatus": "actively-looking",
"relationships": {
"assignmentsAsContact": [{ "id": 88, "name": "VP Engineering Search" }],
"opportunitiesAsContact": [{ "id": 41, "name": "Board Advisory" }],
"assignmentsAsCandidate": [{ "id": 88, "name": "VP Engineering Search" }],
"opportunitiesAsCandidate": [{ "id": 41, "name": "Board Advisory" }],
"listsAsCandidate": [{ "id": 9, "name": "2024 Shortlist" }],
"listsAsContact": []
}
}
What to do
- Read owner.model instead of owner for the person's basic details; read the new owners array if you need every owner, not just the primary one.
- relationships.assignments → relationships.assignmentsAsContact. relationships.opportunities → relationships.opportunitiesAsContact. relationships.candidatesLists → relationships.assignmentsAsCandidate, and note opportunitiesAsCandidate / listsAsCandidate now exist as separate keys rather than being folded into one bucket.
- relationships.lists no longer exists. Use listsAsContact / listsAsCandidate.
- On create (POST /people), replace a single assignmentId/assignmentName (or a lists array) with a projects array:
{ "projects": [{ "projectId": 88, "projectType": "assignment" }] }
or, to create a new project inline:
{ "projects": [{ "projectName": "New Search", "projectType": "assignment" }] }
- If your firm has mandatory-field rules configured, creating or updating a person may now require fields that were previously optional. Check for 422 responses listing unexpected required fields.
- Sending "label": null on an item inside phones or emails will now fail validation. Omit the key instead of sending it as null.
- If you call POST /people/{person}/profile-picture, the returned person object currently reflects an older field set than the rest of v4's people endpoints (it is missing owners, currentStatusCareerStatus, sequences, altrataId, and a few others, and still returns the old flat owner shape). Don't rely on this particular response for the newer fields. Re-fetch the person via GET /people/{person} if you need them.
Companies and Clients
GET/POST/PUT/DELETE /companies, GET /clients, GET /clients/{company}
What changed: Same owner/owners restructuring as People. A consultants field was removed. A relationship list was renamed.
Before (v2)
{
"id": 77,
"name": "Acme Corp",
"owner": { "id": 12, "fullname": "Alex Rivera" },
"consultants": { "users": [], "office_groups": [], "regions": [] },
"relationships": {
"projects": [{ "id": 88, "name": "VP Engineering Search" }]
}
}
After (v4)
{
"id": 77,
"name": "Acme Corp",
"owner": {
"type": "candidate",
"isPrimary": true,
"model": { "id": 12, "fullname": "Alex Rivera" },
"tags": []
},
"owners": [ { "type": "candidate", "isPrimary": true, "model": { "id": 12, "fullname": "Alex
Rivera" }, "tags": [] } ],
"relationships": {
"assignments": [{ "id": 88, "name": "VP Engineering Search" }],
"lists": [{ "id": 9, "name": "2024 Shortlist" }]
},
"manager": {
"mostRecentContextNote": null,
"mostRecentSystemNotes": [],
"mostRecentPinnedNote": null,
"doneTasks": []
}
}
What to do
- Read owner.model / the new owners array as described under People.
- consultants is gone. There's no direct replacement field on the company object; office group / region ownership is now expressed through owner/owners.
- relationships.projects → relationships.assignments.
- On create/update, owner (a plain id) becomes owner.ownerId (now required), with an optional owner.tags array. assignmentId/assignmentName becomes projectId/projectName plus a required projectType.
Lists → People
GET/PUT/DELETE /lists/{id}/people
What changed: No request or validation changes. The list object returned alongside the people (when included) has the same owner/owners/consultants changes as Companies above.
What to do: If you read the list's owner or consultants fields from this endpoint's response, apply the same changes described under Companies.
Projects
GET/POST/PUT/DELETE /projects, PUT /projects/{id}/restore
What changed: Same owner/owners restructuring. consultants removed. Several fields added. One relationship key was reused for a different value, so read this one carefully.
Before (v2)
{
"id": 88,
"label": "assignment",
"startDate": "2024-01-15T00:00:00.000000Z",
"owner": { "id": 12, "fullname": "Alex Rivera" },
"consultants": { "users": [], "officeGroups": [], "regions": [] },
"relationships": {
"company": { "id": 77, "name": "Acme Corp" }
},
"manager": {
"attachments": []
}
}
After (v4)
{
"id": 88,
"label": "assignment",
"oldLabel": "assignment",
"startDate": "2024-01-15",
"owner": {
"type": "candidate",
"isPrimary": true,
"model": { "id": 12, "fullname": "Alex Rivera" },
"tags": []
},
"owners": [ { "type": "candidate", "isPrimary": true, "model": { "id": 12, "fullname": "Alex
Rivera" }, "tags": [] } ],
"portal": { "exists": true, "archived": false },
"relationships": {
"client": { "id": 77, "name": "Acme Corp" },
"company": { "id": 77, "name": "Acme Corp" }
},
"manager": {
"documents": [],
"upcomingTask": null,
"mostRecentResearcherNote": null,
"mostRecentPinnedNote": null,
"duplicates": []
},
"billing": {},
"actions": []
}
What to do
- Read owner.model / owners as above.
- consultants is gone. No direct replacement on the project object.
- Important: relationships.company in v2 is relationships.client in v4. Don't assume the new relationships.company key is the same data, it is sourced differently. If you were reading relationships.company before, switch to relationships.client.
- manager.attachments → manager.documents.
- startDate/endDate are now plain dates (YYYY-MM-DD), not full timestamps.
- On create/update, ownerId becomes owner.ownerId (now required), with an optional owner.tags array. label now also accepts list in addition to assignment/opportunity.
- Listing projects (GET /projects) now applies a permission check that v2 did not, so make sure the calling user has appropriate access.
Project candidates
POST /projects/candidates, GET/DELETE /projects/{project}/candidates
What changed: A new, more general way to add candidates to any project type (assignment, opportunity, or list) was introduced. The older, assignment-only endpoints (POST /assignments/candidates, DELETE /assignments/{assignment}/candidates) still work unchanged, but the newer one is the one to use going forward.
Before (v2)
POST /v2/assignments/candidates
{ "assignmentId": 88, "candidates": [501, 502] }
After (v4)
POST /v4/projects/candidates
{ "projectId": 88, "projectType": "assignment", "candidates": [501, 502], "label":
"assignment" }
What to do
- Use projectId/projectName instead of assignmentId/assignmentName, and add a required label field set to assignment, opportunity, or list depending on what you're adding candidates to.
- GET /projects/{project}/candidates gained a new optional filters parameter and two response-shaping options, withPositionAddresses and simplifiedPositions. Both are optional and default to sensible values, so no action is required unless you want to use them.
Project actions
GET/PUT /projects/{project}/actions
What changed: A tags array was added alongside the existing tag field.
Before (v2): did not exist as a versioned endpoint before v3; if you're currently on v3, no example is needed here beyond the field addition below.
After (v4)
{
"id": 1,
"tag": { "id": 5, "name": "Follow up" },
"tags": [{ "id": 5, "name": "Follow up" }]
}
What to do: When updating an action, you can now send either actions[].tag (a single id) or actions[].tags (an array of ids). Either satisfies validation.
Project target companies
GET/POST /projects/{project}/target-companies
What changed: New endpoint (not present before v4).
After (v4)
GET /v4/projects/88/target-companies
POST /v4/projects/88/target-companies
{ "ids": [101, 102] }
What to do: Optional. Use this if you want to manage a project's target company list directly; no existing integration needs to change because of it.
Billing: invoices
GET /invoices, GET/PUT/PUT/DELETE /invoices/{invoice}, apiResource projects.invoices
What changed: The top-level currency field was folded into total.
Before (v2)
{
"id": 301,
"currency": "USD",
"total": { "amount": "15000.00", "currency": "USD" }
}
After (v4)
{
"id": 301,
"total": { "amount": "15000.00", "currency": "USD" }
}
What to do
- Read currency from total.currency, not a top-level currency field.
- On create/update, send total.currency and total.amount. Both are now required (previously currency was a separate required field and total.amount was not strictly required).
- paidAt/invoicedAt must now be within the last 100 years. An edge case, but worth knowing if you backfill historical records.
- relationships.revenue, relationships.contact, and relationships.project are now omitted from the response entirely when not requested/loaded, rather than present-but-empty. If your code checks for the key's existence rather than its content, update that check.
- If you call the lock/unlock actions: unlocking an invoice no longer removes the previously generated file from storage the way it used to. If your workflow depended on the old file being cleaned up, you may need to handle that yourself.
Billing: revenues
GET /revenues, GET/PUT/PUT/DELETE /revenues/{revenue}, apiResource projects.revenues
What changed: amount renamed to total. Behavior around percent splits on update changed.
Before (v2)
{ "id": 401, "amount": { "amount": "5000.00", "currency": "USD" } }
After (v4)
{ "id": 401, "total": { "amount": "5000.00", "currency": "USD" } }
What to do
- Rename amount to total in both the response you read and the request body you send.
- If you previously relied on omitting percentSplits to clear existing splits on update, that no longer happens. Omitting the field now leaves existing splits untouched. Send an empty array explicitly if you want to clear them.
- If you previously relied on the firm's default percent-split being applied automatically when you created a revenue without sending percentSplits, that default no longer applies automatically. Send the split explicitly if you need it.
Billing: fixed fees
GET /fixed-fees, GET/PUT/PUT/DELETE /fixed-fees/{fixedFee}, apiResource projects.fixed-fees
What changed: amount renamed to total, same as revenues. Note that a percent-split item *embedded inside* an invoice or revenue still uses the old amount key. Only the top-level fixed-fee object changed.
What to do
- Rename amount to total for the fixed-fee object itself.
- Leave amount as-is for any percentSplits[] items you read or send inside an invoice or revenue. That key did not change.
- The total.currency field is now less strictly validated (any string is accepted, not just a known currency code). This doesn't require a change on your end, but don't assume the server will reject an invalid currency code here the way it does for invoices and revenues.
Billing: project summary
GET /projects/{project}/billing
What changed: Nothing in this endpoint's own logic; its invoices, revenues, and fixedFees arrays inherit all the changes described above for those individual endpoints.
What to do: Apply the invoice/revenue/fixed-fee field changes above wherever you read those arrays from this endpoint's response too.
Project contacts
GET/POST/DELETE /projects/{project}/contacts, GET /{model}/{id}/contacts/company
What changed: Viewing a project's or company's primary company contact (/contacts/company) no longer requires edit permission. View access is enough.
What to do: No request/response change. If you were working around a permission error on this specific call, you may no longer need to.
Consultants, office groups, regions
GET/POST/DELETE /{model}/{id}/consultants, /office-groups, /regions
What changed: Nothing about these endpoints themselves changed between v3 and v4, and they still work today. A new, more general Owners API (below) covers similar ground (assigning people, office groups, or regions to a company or project) and is the newer way to do this.
What to do: These endpoints are not broken and don't require any change. If you're setting up ownership/consultant assignment for the first time, consider using the Owners endpoints instead, since they cover companies, projects, and people uniformly.
Tasks and Meetings
GET/POST/PUT/DELETE /tasks, GET/POST/PUT/DELETE /meetings, and the nested per-record variants
What changed: Two request/response fields were renamed. A tags array was added alongside the existing single tag/status field.
Before (v2)
{
"title": "Intro call",
"owners": [12],
"channels": ["email"],
"tag": 5
}
After (v4)
{
"title": "Intro call",
"assignees": [12],
"context": ["email"],
"tag": 5,
"tags": [5]
}
What to do
- Rename owners to assignees and channels to context in requests you send.
- On the response side, owners becomes assignees and channels becomes context as well.
- You can send either tag (a single id) or tags (an array). But when creating a task (POST /tasks), only tags is currently honored; if you send only the older tag field on create, it will be silently ignored. Send tags on task creation to be safe. (Updating a task with only tag works correctly.)
Notes
GET/POST/PUT/DELETE /{model}/{id}/notes, POST/PUT/DELETE /people/notes, PUT /notes/{note}/sort, GET /notes
What changed: Filtering and sorting options expanded significantly. New note types are returned. A few edge-case behaviors changed.
What to do
- New optional query parameters are available for filtering notes: withPinned, noteType, tags, contacts, companies, projects, since/before/between, fuzzy, plus sort/order controls. None of these are required. Existing calls without them continue to work.
- These endpoints can now return system, compliance, and feedback notes in addition to research/user notes. If your integration assumed every note was a research note, check the note's type before processing it.
- If you create a note and pass ownerId to attribute it to someone other than the calling user, that's currently ignored on create. The note is always attributed to the calling user. ownerId does work correctly when *updating* an existing note's owner.
- Updating a note and omitting noteText no longer clears the note's text. It now leaves the existing text unchanged. If you were relying on omitting noteText to blank out a note, send an empty string explicitly instead.
- Pinning a note (sort) now only works for research notes. Attempting to pin a system, compliance, or feedback note returns an error.
- Note text returned by the API is no longer passed through HTML cleanup before being sent back. Treat it as raw stored content.
- GET /notes no longer accepts a count parameter to control page size. Page size is fixed by your firm's configuration.
Endpoints removed
- `/v2/categories/{category}` is being removed. It duplicated the unversioned /categories endpoint (see Categories below) but only worked for 4 fixed category types (industries, locations, careers, companies). Use /categories directly instead. It's not versioned and covers the same data.
Endpoints added since v2
These are new capabilities. You don't need any of them to migrate. They're listed for completeness.
- Owners: PUT /{model}/{id}/primary-owner, GET/POST/DELETE /{model}/{id}/owners, POST/DELETE /{model}/{id}/owners/tags. Assign a person, office group, or region as an owner of a company, project, or person record (including leads and candidates).
- Project target companies: GET/POST /projects/{project}/target-companies.
- Project actions, convert, completion: GET/PUT /projects/{project}/actions, PUT /projects/{project}/convert, PUT /projects/{project}/completion.
- Project contacts, consultants, office groups, regions: /{model}/{id}/contacts, /consultants, /office-groups, /regions for companies and projects.
- `GET /projects-increment`: the next available project reference number.
- `GET /documents/{document}` on companies/projects/assignments/opportunities/people/lists. Fetch a single attached document directly.
- KPI Dashboards (read-only): GET /kpi-dashboards, /kpi-dashboards/global, /kpi-dashboards/shared, GET /kpi-dashboards/{id}. Requires a query-builder permission on your account.
Categories
/categories and /categories/{category} are unversioned reference data (careers, industries, locations, and similar lookup lists) and are not part of the API version scheme. Call them directly with no version prefix, using the same authentication as the rest of the API. They're not affected by this migration and will continue to work the same way after v1/v2 are retired.
Was this article helpful?
That’s Great!
Thank you for your feedback
Sorry! We couldn't be helpful
Thank you for your feedback
Feedback sent
We appreciate your effort and will try to fix the article