About this version #
A read-only HTTP API over your Shortlists data: candidates, applications, contacts, companies, jobs, notes, tasks, attachments and users. It is designed for two jobs — a full or incremental sync of an entity into your own system, and a low-latency lookup of a person by phone number or email address while a call is in progress.
This documents v1 of the Shortlists REST API, which is live now — you
can build against it today. It is read-only: the endpoints, parameters and response
shapes described here are the v1 contract. Where we need to make a change that would
break an existing integration, it will go out under a new version (a /v2/
path) rather than silently changing /v1/ underneath you.
Write access — creating and updating records — is the next tier we are building. If a field or a behaviour is awkward to consume, or there is a particular write you need most, tell us early: that feedback shapes what comes next while it is still cheap to act on.
1. Base URL and versioning #
| Base URL |
https://api.shortlists.io —
prepend it to every path in this document.
|
| Transport | HTTPS only. Plain, unencrypted requests are rejected. |
| Content type |
application/json on every response with a body. Bodies are UTF-8.
|
| Version prefix | Every path begins with /v1/. |
| Methods | GET only in v1. See §7. |
Your base URL is
https://api.shortlists.io. Every path
shown in this document is relative to it — for example /v1/candidates is
requested at
https://api.shortlists.io/v1/candidates.
GET https://api.shortlists.io/v1/candidates?limit=100
Versioning policy
Within /v1/ we may add new endpoints, add new fields to an existing
response, and add new values to an existing enumerated field. We will not remove a
field, rename a field, change a field's type, or change the meaning of an existing
value. Any change that would break a client built against this document ships under a
new prefix (/v2/), and /v1/ continues to be served.
Treat responses as open maps: ignore fields you do not recognise rather than failing on them.
2. Authentication #
Every request carries a bearer token:
Authorization: Bearer sk_live_…
The Authorization: Bearer header is the only accepted form of
authentication. Keys are not accepted in a query string or in any other header.
3. Conventions #
3.1 Pagination #
List endpoints are keyset-paginated. Two query parameters control it:
| Parameter | Type | Default | Notes |
|---|---|---|---|
limit |
integer | 100 |
Maximum 500. A value above 500 is clamped to 500. Must be
plain decimal digits — 250 is accepted;
2.5e2, +250, 250.0,
250
(with a leading space) and a value below 1 are all rejected with
400 BAD_REQUEST rather than clamped or coerced.
|
cursor |
string | — | Opaque. Pass back the next_cursor you were given, verbatim. |
sort |
string | updated_at |
Optional, and updated_at is the only accepted value. List endpoints
are always served oldest-first by (updated_at, id) — that ordering
is what makes the cursor stable — so any other value returns
400 BAD_REQUEST rather than being silently ignored. The notes
sub-resource is newest-first and takes -created_at.
|
Every list response carries the same envelope:
{
"data": [],
"next_cursor": "eyJ1IjoiMjAyNi0wOC0xMlQxNDoyMjowOS4zMTFaIiwiaSI6IjNmOWEwYzE0LTdkNTItNDgxMS1iM2VlLTkwYzExN2E2NDJkNSJ9",
"total_count": 12480,
"request_id": "req_1786371729341_9f4c2a7e"
}
total_count is returned only on the first request of a page
sequence
— that is, only when no cursor is supplied. On every subsequent page the
field is absent. An exact count on every page would mean a second full scan per request,
adding latency to a path that is used during live calls; reconciliation only needs the
total once per sync run, so that is when we compute it.
total_count respects updated_since: with the parameter present
it is the number of records modified at or after that instant, not the size of the whole
entity.
A worked loop:
GET /v1/candidates?limit=500 → total_count: 12480, next_cursor: "AAA…"
GET /v1/candidates?limit=500&cursor=AAA… → (no total_count), next_cursor: "BBB…"
GET /v1/candidates?limit=500&cursor=BBB… → (no total_count), next_cursor: null ← done
3.2 Incremental reads #
Every list endpoint accepts updated_since:
| Parameter | Type | Notes |
|---|---|---|
updated_since |
string · ISO 8601 |
For example 2026-08-01T00:00:00Z. Inclusive: records whose
updated_at
is exactly this instant are returned. A value we cannot parse returns
400 BAD_REQUEST.
|
The accepted format, exactly: YYYY-MM-DD, optionally
followed by a time (Thh:mm, Thh:mm:ss or
Thh:mm:ss.sss — a space instead of the T is accepted),
optionally followed by Z or a ±hh:mm offset. Nothing else is
accepted:
anything outside that shape returns 400 BAD_REQUEST naming the expected
format
— including values that are technically valid ISO 8601 but reduced-precision
(2026, 2026-08), locale-dependent forms
(08/10/2026), epoch milliseconds, and — worth a specific warning —
the default JavaScript Date.prototype.toString() output
(Mon Aug 10 2026 00:00:00 GMT+0100 (British Summer Time)). Build watermarks
with date.toISOString(), never with string concatenation or
String(date). We reject rather than guess because a misread watermark
silently skips or re-delivers records, and on a sync that is the one failure you cannot
see.
Send a timezone. 2026-08-01T00:00:00Z and
2026-08-01T01:00:00+01:00 are both unambiguous and are honoured exactly as
given. A value with no timezone — 2026-08-01T00:00:00 — is
interpreted as UTC. That is a deliberate, documented choice rather than
a local-time guess.
Records are ordered by last-modified ascending, and that ordering is stable across pages. Ties are broken internally by record id, so two records modified in the same instant — which happens on every bulk edit — always come back in the same relative order and can never be skipped or repeated at a page boundary.
The consequence worth designing around: the last record of a completed run tells you
where to resume. Record the updated_at of the final record you received and
pass it as the next run's updated_since. Because the comparison is
inclusive you will see that record again, so make your ingest idempotent on
id.
Do not use wall-clock "now" as your next watermark. Use the data.
3.3 Errors #
Every error has an HTTP status and this body:
{
"error": {
"code": "BAD_REQUEST",
"message": "limit must be a positive integer"
},
"request_id": "req_1786371729341_9f4c2a7e"
}
| Status | code |
Meaning |
|---|---|---|
| 400 | BAD_REQUEST |
A parameter is missing, malformed, or out of range. A rejected
cursor, an unparseable updated_since, a non-integer
limit, an unsupported sort, a malformed attachment id,
and a sub-resource that the entity does not support all land here.
|
| 401 | UNAUTHORIZED |
No key, or the key is unknown or revoked. |
| 403 | FORBIDDEN_SCOPE |
The key is valid but does not carry the scope this endpoint needs. |
| 404 | NOT_FOUND |
No such path, no such sub-resource name, or no such record in this workspace. |
| 405 | METHOD_NOT_ALLOWED |
The path exists but the method does not. v1 serves GET only; the
response carries an Allow header. Distinct from
BAD_REQUEST so you can tell a wrong verb from a wrong parameter.
|
| 429 | RATE_LIMITED |
The per-key request budget is exhausted. See §3.4. |
| 500 | INTERNAL_ERROR |
A fault on our side. Safe to retry with backoff. |
| 503 | SERVICE_UNAVAILABLE |
The API is temporarily not fully available and is declining the request rather
than answering it incompletely. Wait for the number of seconds in
Retry-After, then retry.
|
Those eight codes are the complete set. A record that exists in another workspace
returns 404 NOT_FOUND, identically to a record that does not exist. This is
deliberate and not a bug to work around. An id that is not in the expected format also
returns 404 NOT_FOUND rather than 400.
One exception to that rule: the two sub-resources,
GET /v1/{entity}/{id}/notes and
GET /v1/{entity}/{id}/attachments, return 200 with an empty
data array (and total_count: 0) for an id that does not exist
or belongs to another workspace — the same response as a record of yours that genuinely
has no notes or attachments. No information leaks either way; the shape is just
different. If you need to distinguish "no notes" from "not my record",
GET /v1/{entity}/{id} first: it is the endpoint that answers existence.
request_id is returned on every response, successful or not
— it sits alongside data in a 2xx body and alongside
error in a failure. It is unique to the request.
Quote it whenever you report a problem: it is how we locate the exact
request in our logs, and a report without one is materially slower to diagnose. Log it
on every non-2xx response at minimum.
Branch on error.code and the HTTP status, never on
error.message. Message text is written for humans and will change.
429 and 503 both carry a
Retry-After header, in seconds. Handle them the same way: wait that long, then retry the identical
request. A 503 is not a fault in your request — it means we are declining
to answer rather than answer incompletely — so retrying unchanged is the correct
response, and there is nothing to investigate on your side.
3.4 Rate limits #
600 requests per minute per key. Beyond that, requests return
429 RATE_LIMITED with a Retry-After header giving the number
of seconds to wait:
HTTP/1.1 429 Too Many Requests
Retry-After: 18
Wait for that long, then retry. Do not retry a 429 immediately, and do not
run your sync at a concurrency that relies on being throttled.
The budget is generous on purpose so that a phone lookup on every inbound call is never
the thing that exhausts it. If you are close to the limit during a bulk sync, raise
limit to 500 rather than increasing request concurrency — the same data in
fewer requests.
4. Entities #
Nine entities. Every record has a string id, a created_at and
an updated_at.
Shared conventions
4.1 candidates
#
A record represents a candidate's involvement, not only the person. Read §6.1 before you build against this entity.
| Field | Type | Notes |
|---|---|---|
id |
string | This record. |
person_id |
string |
Identifies the human. Identical across every record belonging to one person, and
directly comparable with the person_id returned by
GET /v1/search/phone and GET /v1/search/email.
|
is_primary_record |
boolean |
true on the person's main record; false on a record
tied to a specific job.
|
name |
string | Full name. |
emails |
array of string | Zero or one entry in v1 — an array for forward compatibility. See §7. |
phone |
string | Normalised form. |
phone_as_entered |
string | The number exactly as it was originally entered or imported. |
mobile |
null | Always null in v1. See §7. |
job_title |
string | The candidate's own current or target title. |
company |
string | The candidate's current employer. |
location |
string | Free text as entered. |
linkedin_url |
string | |
stage |
string | Pipeline stage name. Null when the record is not on a job. Stage names are workspace-configurable. |
stage_type |
string | The stage's category — stable across renames, so prefer this for logic. Null when not on a job. |
status |
string |
placed, rejected, otherwise the
stage_type, otherwise active.
|
owning_recruiter_id |
string | Null unless genuinely known — see the shared conventions above. |
job_id |
string |
The jobs.id this record is tied to. Null on the primary record.
|
salary |
object | See below. |
consent |
object | See below. |
created_at |
string | |
updated_at |
string |
salary — the amount/currency pair is the structured value; the
_text field is what a recruiter typed, kept because it often carries detail
no number can ("£85k + car + 20% bonus").
| Field | Type |
|---|---|
salary.current_amount |
number |
salary.current_currency |
string |
salary.current_text |
string |
salary.expected_amount |
number |
salary.expected_currency |
string |
salary.expected_text |
string |
consent — one lifecycle covering both data-processing consent and
right-to-represent.
| Field | Type |
|---|---|
consent.status |
string |
consent.given_at |
string · timestamp |
consent.requested_at |
string · timestamp |
consent.expires_at |
string · timestamp |
4.2 applications
#
One candidate put forward for one job. Every application corresponds to a
candidates record with a job_id; this entity is the same link
seen from the job's side, with the fields relevant to a pipeline and nothing else.
| Field | Type | Notes |
|---|---|---|
id |
string | |
candidate_id |
string |
The person. Equal to that person's
candidates.person_id.
|
candidate_row_id |
string |
The specific candidates.id this application is carried by — the
addressable record.
|
candidate_name |
string | |
job_id |
string | |
stage |
string | Pipeline stage name. |
stage_type |
string | Stage category. Prefer this for logic. |
status |
string |
placed, rejected, otherwise the
stage_type, otherwise active.
|
rejected_at |
string · timestamp | Null unless rejected. |
created_at |
string | |
updated_at |
string |
id, candidate_row_id and the corresponding
candidates.id are the same value. candidate_id is different:
it is the person.
4.3 contacts
#
A person at a client company.
| Field | Type | Notes |
|---|---|---|
id |
string | |
name |
string | |
emails |
array of string | Zero or one entry in v1. See §7. |
phone |
string | Normalised form. |
phone_as_entered |
string | As originally entered or imported. |
mobile |
null | Always null in v1. See §7. |
job_title |
string | |
department |
string | |
company_id |
string | The companies.id this contact belongs to. Never null. |
company |
string | That company's name, denormalised for convenience. |
linkedin_url |
string | |
is_main_contact |
boolean | The primary contact for that company. |
owning_recruiter_id |
string | |
created_at |
string | |
updated_at |
string |
4.4 companies
#
| Field | Type | Notes |
|---|---|---|
id |
string | |
name |
string | |
phone |
string | The company's main contact number. Often the direct number of one of its contacts rather than a separate switchboard line. |
website_url |
string | |
linkedin_url |
string | |
status |
string | Commercial relationship status. Workspace-configurable. |
is_archived |
boolean | Archived companies remain readable and remain in the sync stream. |
owning_recruiter_id |
string | |
created_at |
string | |
updated_at |
string |
4.5 jobs
#
A vacancy being worked.
| Field | Type | Notes |
|---|---|---|
id |
string | |
title |
string | The job's title. |
position |
string | The position as recorded on the job. Often equal to title. |
company |
string | Client company name. |
company_id |
string |
The companies.id. May be null on a job with no client attached.
|
status |
string | Placement status. |
role_status_id |
string | Identifier of the workspace-configured status. |
live |
boolean |
true when the role is open — that is, it has not
been archived. This is the "is this vacancy still running" flag, and it is the
exact complement of is_archived.
|
published |
boolean |
true when the role is not archived and is additionally
advertised on the company's public careers page.
|
is_archived |
boolean | |
salary |
object |
salary.amount (number), salary.currency (string),
salary.type (string — how the remuneration is expressed).
|
owning_recruiter_id |
string | The assigned recruiter, falling back to whoever created the job. |
hiring_manager |
string | Free text. |
created_at |
string | |
updated_at |
string |
live and published are two different questions, and most
roles answer them differently.
live says the role is open — it has not been archived, so a recruiter may
still be working it. published says the role is also on public display,
advertised on the company's careers page for anyone to find and apply to. Most open
roles are worked privately and are therefore
live: true, published: false.
If you want the roles a recruiter is currently filling, filter on live.
If you want the roles a member of the public could see advertised, filter on
published. A role is never published: true while
live: false.
4.6 notes
#
Recruiter notes. One entity across all four things a note can hang off, discriminated by
parent_type.
| Field | Type | Notes |
|---|---|---|
id |
string | |
parent_type |
string |
candidate, contact, company or
job.
|
parent_id |
string |
Id of the record the note is filed against, in the entity named by
parent_type.
|
title |
string | |
body_html |
string | The note body as HTML. Sanitise before rendering. |
type |
string |
The note's label, e.g. Phone call. null when the note
is unlabelled. Not a fixed set — see below.
|
is_private |
boolean | Whether the author marked the note private. Private notes are not returned — see §7. |
author_id |
string | The users.id of the author. |
job_id |
string |
Only on parent_type: "candidate". The job the note
was written in the context of, when there was one.
|
company_id |
string |
Only on parent_type: "contact". The contact's
company.
|
created_at |
string | |
updated_at |
string |
job_id and company_id are absent, not null, on the
parent_type values that do not carry them.
type
type is an open, per-account vocabulary, not an enumeration.
Note labels are created and edited by the recruiters using the account: two are
provided as a starting point and every other one is typed by a user or created by
whatever system imported the note. So the values you see are whatever your own team
has used, they can be renamed or deleted at any time, and a value that appears today
may not appear tomorrow.
Treat it as a display and grouping string. Do not branch on it, and
do not assume any particular value exists — if you need to key behaviour off a label,
agree the exact spellings with the account owner first and handle the unknown case.
type is null on notes filed without a label, which is a
small but real minority.
The same label vocabulary is shared across all four parent_type values,
so Phone call on a contact note means the same thing as
Phone call on a candidate note.
There is no equivalent field on tasks — tasks are not
labelled. Their status describes progress, not classification, and the
two are not interchangeable.
4.7 tasks
#
| Field | Type | Notes |
|---|---|---|
id |
string | |
parent_type |
string |
candidate, contact or company. Tasks are
never filed against a job.
|
parent_id |
string | |
title |
string | |
body_html |
string | Description as HTML. |
status |
string | |
due_date |
string · date | |
owner_id |
string | The users.id the task is assigned to. |
completed_at |
string · timestamp | Null while outstanding. |
is_private |
boolean | Private tasks are not returned — see §7. |
created_at |
string | |
updated_at |
string |
4.8 attachments
#
Files. CVs, generated CV documents, and documents uploaded against a candidate or a company.
| Field | Type | Notes |
|---|---|---|
id |
string | Prefixed — see below. |
parent_type |
string | candidate or company. |
parent_id |
string | |
filename |
string | Display name. Not guaranteed unique within a parent. |
content_type |
string | MIME type. Null when it was never recorded. |
size |
integer | Bytes. Null when unknown. |
kind |
string |
cv (the candidate's CV), generated_cv (a formatted CV
we produced), document (anything else).
|
storage_ref |
string |
The stored reference, reduced to a bucket/path-style identifier and
exposed for diagnostics — correlate it with what you see in a download URL or
quote it in a support request. It is not a URL and carries no credentials;
fetching bytes goes through the download endpoint, which is the only
supported path.
|
created_at |
string | |
updated_at |
string |
An attachment id is <kind>:<identifier>, where the prefix is
one of cv:, file:, cvdoc: or
clientfile: — for example
file:6c1e9d40-2b77-4a5e-9d31-8f0aa1b2c3d4. Attachments are drawn from
several sources with independent identifier spaces, and the prefix is what makes an id
globally unique and directly resolvable.
Pass the whole thing, prefix included, to
GET /v1/attachments/{id}/download. A colon is legal in a path segment, so
no escaping is needed, though a percent-encoded %3A is accepted too.
GET /v1/attachments/{id} accepts the id
either with or without the prefix, so you can read an id off a list
response and fetch it again unchanged.
Attachments whose kind is cv inherit the timestamps of the
candidate they belong to, because a candidate's CV is a property of the candidate rather
than a separately versioned record. They still appear correctly in the
updated_since stream.
4.9 users
#
Members of the workspace — the recruiters that owning_recruiter_id,
author_id and owner_id point at.
| Field | Type | Notes |
|---|---|---|
id |
string | |
name |
string | Full name. |
email |
string | |
active |
boolean |
true for a member whose access is live. false for an
invitation that was never accepted.
|
role |
string | Their role in the workspace. |
external_id |
null | Always null in v1. See §7. |
created_at |
string | |
updated_at |
string |
A change to any of a member's own details or to their workspace access — name,
email, role, or an invitation being accepted — moves updated_at, so an
incremental read picks all of them up.
5. Endpoints #
{entity} is one of candidates, applications,
contacts, companies, jobs, notes,
tasks, attachments, users. Any other value
returns 404 NOT_FOUND.
| Method | Path | Purpose |
|---|---|---|
GET |
/v1/{entity} |
List, paginated, incremental. |
GET |
/v1/{entity}/{id} |
One record. |
GET |
/v1/{entity}/{id}/notes |
A record's notes, newest first. |
GET |
/v1/{entity}/{id}/attachments |
A record's files. |
GET |
/v1/attachments/{id}/download |
Redirect to a time-limited download URL. |
GET |
/v1/search/phone |
Find a person by phone number. |
GET |
/v1/search/email |
Find a person by email address. |
GET |
/v1/counts |
Record counts per entity. |
GET/v1/{entity} #
| Parameter | Type | Default | Notes |
|---|---|---|---|
updated_since |
ISO 8601 | — | Inclusive. |
limit |
integer | 100 | Maximum 500. |
cursor |
string | — | From a previous next_cursor. |
GET /v1/candidates?updated_since=2026-08-01T00:00:00Z&limit=2
Authorization: Bearer sk_live_…
{
"data": [
{
"id": "3f9a0c14-7d52-4811-b3ee-90c117a642d5",
"person_id": "3f9a0c14-7d52-4811-b3ee-90c117a642d5",
"is_primary_record": true,
"name": "Priya Raghunathan",
"emails": ["priya.raghunathan@example.com"],
"phone": "+447700900412",
"phone_as_entered": "07700 900412",
"mobile": null,
"job_title": "Group Financial Controller",
"company": "Ashcombe Retail Group",
"location": "Manchester, UK",
"linkedin_url": "https://www.linkedin.com/in/priya-raghunathan-example",
"stage": null,
"stage_type": null,
"status": "active",
"owning_recruiter_id": "b52f7c81-4a19-4d0e-8b6c-1e2f3a4b5c6d",
"job_id": null,
"salary": {
"current_amount": 92000,
"current_currency": "GBP",
"current_text": "£92,000 + 15% bonus",
"expected_amount": 110000,
"expected_currency": "GBP",
"expected_text": "£110k"
},
"consent": {
"status": "granted",
"given_at": "2026-07-14T09:31:02.004Z",
"requested_at": "2026-07-13T16:02:55.870Z",
"expires_at": "2028-07-14T09:31:02.004Z"
},
"created_at": "2026-07-13T16:02:55.870Z",
"updated_at": "2026-08-11T10:04:18.229Z"
},
{
"id": "9d7b2e55-0c31-4f88-a2d6-77bc41e0f3a9",
"person_id": "3f9a0c14-7d52-4811-b3ee-90c117a642d5",
"is_primary_record": false,
"name": "Priya Raghunathan",
"emails": ["priya.raghunathan@example.com"],
"phone": "+447700900412",
"phone_as_entered": "07700 900412",
"mobile": null,
"job_title": "Group Financial Controller",
"company": "Ashcombe Retail Group",
"location": "Manchester, UK",
"linkedin_url": "https://www.linkedin.com/in/priya-raghunathan-example",
"stage": "Client Interview",
"stage_type": "interview",
"status": "interview",
"owning_recruiter_id": "b52f7c81-4a19-4d0e-8b6c-1e2f3a4b5c6d",
"job_id": "e41c8a06-9b2d-4c7f-8e10-5a6b7c8d9e0f",
"salary": {
"current_amount": 92000,
"current_currency": "GBP",
"current_text": "£92,000 + 15% bonus",
"expected_amount": 110000,
"expected_currency": "GBP",
"expected_text": "£110k"
},
"consent": {
"status": "granted",
"given_at": "2026-07-14T09:31:02.004Z",
"requested_at": "2026-07-13T16:02:55.870Z",
"expires_at": "2028-07-14T09:31:02.004Z"
},
"created_at": "2026-07-28T11:47:33.512Z",
"updated_at": "2026-08-12T14:22:09.311Z"
}
],
"next_cursor": "eyJ1IjoiMjAyNi0wOC0xMlQxNDoyMjowOS4zMTFaIiwiaSI6IjlkN2IyZTU1LTBjMzEtNGY4OC1hMmQ2LTc3YmM0MWUwZjNhOSJ9",
"total_count": 1184,
"request_id": "req_1786371729341_9f4c2a7e"
}
Both records above are the same human — one main record, one for the job she is on. See §6.1.
GET/v1/{entity}/{id} #
No query parameters. Returns the same object a list would return, wrapped in
data. Returns 404 NOT_FOUND if the id does not exist in your
workspace.
For attachments, this endpoint accepts the id with or without its
<kind>: prefix; the prefixed form is what
GET /v1/attachments/{id}/download requires.
GET /v1/jobs/e41c8a06-9b2d-4c7f-8e10-5a6b7c8d9e0f
Authorization: Bearer sk_live_…
{
"data": {
"id": "e41c8a06-9b2d-4c7f-8e10-5a6b7c8d9e0f",
"title": "Group Financial Controller",
"position": "Group Financial Controller",
"company": "Ashcombe Retail Group",
"company_id": "7a3d5f19-8c02-4e6b-9f11-2d3e4f5a6b7c",
"status": "shortlisting",
"role_status_id": "c9e1a2b3-4d5e-4f60-8a71-9b2c3d4e5f60",
"live": true,
"published": false,
"is_archived": false,
"salary": {
"amount": 115000,
"currency": "GBP",
"type": "annual"
},
"owning_recruiter_id": "b52f7c81-4a19-4d0e-8b6c-1e2f3a4b5c6d",
"hiring_manager": "Duncan Mowbray",
"created_at": "2026-06-30T08:15:41.663Z",
"updated_at": "2026-08-12T09:58:02.117Z"
},
"request_id": "req_1786371731028_3c81de55"
}
GET/v1/{entity}/{id}/notes #
Available for candidates, applications, contacts,
companies and jobs. Any other entity returns
400 BAD_REQUEST.
| Parameter | Type | Default | Notes |
|---|---|---|---|
limit |
integer | 100 | Maximum 500. |
sort |
string | -created_at |
Optional. -created_at is the only accepted value; any other value
returns 400 BAD_REQUEST rather than being silently ignored.
|
Notes come back newest first. This endpoint is not cursor-paginated: it
returns the most recent limit notes for the record, and the response
carries total_count — the full number of notes the record
has, regardless of limit. When total_count exceeds the number
of rows in data, you received the most recent slice; raise
limit (max 500) or, to sync every note in the workspace, use
GET /v1/notes with updated_since. Compare the two rather than
assuming a short page means you have everything — records with several hundred notes are
normal in a mature account.
For a candidate or an application this returns the whole person's notes — see §6.2.
Each note carries a type (§4.6) — the label the
recruiter filed it under, such as Phone call or
Email Received. If you are pulling the most recent note to give somebody
context on a live call, read it: the newest note means something quite different
depending on whether it records a conversation, an inbound email or a status change. It
is null on unlabelled notes, and the label vocabulary is specific to the
account — see §4.6 before relying on any particular value.
GET /v1/candidates/9d7b2e55-0c31-4f88-a2d6-77bc41e0f3a9/notes?limit=2
Authorization: Bearer sk_live_…
{
"data": [
{
"id": "1c4d6e8f-0a2b-4c3d-9e5f-6a7b8c9d0e1f",
"parent_type": "candidate",
"parent_id": "9d7b2e55-0c31-4f88-a2d6-77bc41e0f3a9",
"title": "Client interview feedback",
"body_html": "<p>Strong on the group consolidation piece. Ashcombe want a second conversation with the CFO before making an offer.</p>",
"type": "Client interview",
"is_private": false,
"author_id": "b52f7c81-4a19-4d0e-8b6c-1e2f3a4b5c6d",
"job_id": "e41c8a06-9b2d-4c7f-8e10-5a6b7c8d9e0f",
"created_at": "2026-08-12T14:21:55.902Z",
"updated_at": "2026-08-12T14:22:09.311Z"
},
{
"id": "5b8f1a92-3c4d-4e5f-a607-1b2c3d4e5f60",
"parent_type": "candidate",
"parent_id": "3f9a0c14-7d52-4811-b3ee-90c117a642d5",
"title": "Initial screening call",
"body_html": "<p>Twelve years in retail finance, wants to move within the quarter. Notice period one month. Open to hybrid, two days on site.</p>",
"type": "Phone call",
"is_private": false,
"author_id": "d0f3b7a2-5e61-4c92-8a03-4b5c6d7e8f90",
"job_id": null,
"created_at": "2026-07-14T10:12:44.318Z",
"updated_at": "2026-07-14T10:12:44.318Z"
},
{
"id": "7c1e4b60-8d92-4a35-b0f7-2e3d4c5b6a71",
"parent_type": "candidate",
"parent_id": "3f9a0c14-7d52-4811-b3ee-90c117a642d5",
"title": "",
"body_html": "<p>Left a voicemail on the mobile, will try again Thursday morning.</p>",
"type": null,
"is_private": false,
"author_id": "d0f3b7a2-5e61-4c92-8a03-4b5c6d7e8f90",
"job_id": null,
"created_at": "2026-07-11T16:44:02.115Z",
"updated_at": "2026-07-11T16:44:02.115Z"
}
],
"total_count": 41,
"request_id": "req_1786371733517_0b7ae294"
}
Note the two different parent_id values: the request asked about one record
and got the person's full history, including the note filed against her main record.
GET/v1/{entity}/{id}/attachments #
Available for candidates, applications and
companies. Any other entity returns 400 BAD_REQUEST. One
optional parameter: limit (default 500, max 500).
Attachments come back newest first, and the response carries
total_count — the full number of attachments the record
has. Like notes, this endpoint is not cursor-paginated: it returns the most recent
limit attachments, so when total_count exceeds the rows in
data you received the most recent slice. The default equals the maximum, so
in practice the response is complete for any record with up to 500 attachments; to sync
every attachment in the workspace regardless of count, use
GET /v1/attachments with updated_since.
For a candidate or an application this returns the whole person's attachments — see §6.2.
GET /v1/candidates/9d7b2e55-0c31-4f88-a2d6-77bc41e0f3a9/attachments
Authorization: Bearer sk_live_…
{
"data": [
{
"id": "cvdoc:2f6a8b1c-9d0e-4f12-a345-6b7c8d9e0f11",
"parent_type": "candidate",
"parent_id": "3f9a0c14-7d52-4811-b3ee-90c117a642d5",
"filename": "generated-cv.pdf",
"content_type": "application/pdf",
"size": null,
"kind": "generated_cv",
"storage_ref": "generated/2f6a8b1c-9d0e-4f12-a345-6b7c8d9e0f11.pdf",
"created_at": "2026-08-02T13:40:06.771Z",
"updated_at": "2026-08-02T13:40:06.771Z"
},
{
"id": "file:6c1e9d40-2b77-4a5e-9d31-8f0aa1b2c3d4",
"parent_type": "candidate",
"parent_id": "3f9a0c14-7d52-4811-b3ee-90c117a642d5",
"filename": "Priya Raghunathan - references.pdf",
"content_type": "application/pdf",
"size": 184320,
"kind": "document",
"storage_ref": "uploads/3f9a0c14/references.pdf",
"created_at": "2026-07-21T15:09:12.005Z",
"updated_at": "2026-07-21T15:09:12.005Z"
},
{
"id": "cv:3f9a0c14-7d52-4811-b3ee-90c117a642d5",
"parent_type": "candidate",
"parent_id": "3f9a0c14-7d52-4811-b3ee-90c117a642d5",
"filename": "priya-raghunathan-cv.docx",
"content_type": null,
"size": null,
"kind": "cv",
"storage_ref": "uploads/3f9a0c14/priya-raghunathan-cv.docx",
"created_at": "2026-07-13T16:02:55.870Z",
"updated_at": "2026-08-11T10:04:18.229Z"
}
],
"total_count": 3,
"request_id": "req_1786371735902_a41cf6b0"
}
GET/v1/attachments/{id}/download #
Takes the prefixed attachment id, exactly as returned in
an attachment payload.
| Parameter | Type | Default | Notes |
|---|---|---|---|
redirect |
string | — |
Pass false to receive the URL as JSON instead of a redirect. Any
other value, or omitting it, gives the redirect.
|
By default it responds 302 Found with a Location header
pointing at a signed URL valid for 60 minutes. Follow the redirect to
fetch the bytes. Most HTTP clients do this automatically; if yours does not, read
Location and issue a second request.
The hostname in the examples below is illustrative, like the base URL in §1 — do not
allowlist it.
The real Location is a short-lived signed URL on our storage host. Treat
the whole URL as opaque: if your network egress is allowlisted by hostname, allowlist
the host you observe in a real response (we will tell you ahead of time if it changes),
never the documentation's placeholder.
GET /v1/attachments/file:6c1e9d40-2b77-4a5e-9d31-8f0aa1b2c3d4/download
Authorization: Bearer sk_live_…
HTTP/1.1 302 Found
Location: https://files.shortlists.io/signed/6c1e9d40-2b77-4a5e-9d31-8f0aa1b2c3d4/references.pdf?expires=1786374000&signature=b7f21c9e4a0d
With ?redirect=false the same call returns the URL for you to fetch
yourself:
{
"data": {
"url": "https://files.shortlists.io/signed/6c1e9d40-2b77-4a5e-9d31-8f0aa1b2c3d4/references.pdf?expires=1786374000&signature=b7f21c9e4a0d",
"signed": true,
"expires_in": 3600,
"filename": "Priya Raghunathan - references.pdf",
"content_type": "application/pdf"
},
"request_id": "req_1786371738440_5d20ba7c"
}
expires_in is in seconds. signed tells you that the URL
carries a signature and therefore an expiry; in v1 a successful response always has
signed: true, because an attachment we cannot produce a working signed URL
for is a 404 instead. The field is published so that a client branching on
it does not have to handle its disappearance later.
Rules worth encoding in your client
GET/v1/search/phone #
Find who is calling. Read §6.3 before you build against this — it
is confidence-scored, and ambiguous requires the caller to make a choice
rather than assume one.
| Parameter | Type | Default | Notes |
|---|---|---|---|
number |
string | — |
Required. Any format: national, international, spaced, bracketed.
phone and q are accepted as aliases.
|
limit |
integer | 100 | Maximum 100. Applies to the candidates array. |
Omitting the number returns 400 BAD_REQUEST.
Inside data the shape is not the standard list envelope — it is a single
match result:
| Field | Type | Notes |
|---|---|---|
query |
string | The number exactly as you sent it. |
match_key |
string |
The normalised key the match was made on. Diagnostic. Absent when
match_confidence is invalid.
|
match_strategy |
string |
last9 or last8_fallback — how the match was made. See
“Partial numbers” below.
|
match_confidence |
string |
exact, ambiguous, none or
invalid.
|
reason |
string | Present only when match_confidence is invalid. |
candidates |
array | One entry per person, not per record. |
contacts |
array |
Matching client contacts. These are people, and they count towards
match_confidence.
|
companies |
array |
Companies whose main contact number matches. Returned as context and
not counted towards match_confidence. May be
populated even when match_confidence is none.
|
Each entry in candidates:
| Field | Type | Notes |
|---|---|---|
person_id |
string |
Stable identifier for this person. Use it to group and to deduplicate results,
and to look the person up in GET /v1/candidates — it is the same
person_id that entity publishes.
|
name |
string | |
phone |
string | |
email |
string | |
owning_recruiter_id |
string | The recruiter to route the call to, when we know it. |
records |
array |
The person's individual candidate records: candidate_row_id,
job_id, is_primary_record, stage. The
primary record comes first.
|
Each entry in contacts: id, name,
phone, email, company_id,
owning_recruiter_id. Each entry in companies: id,
name, phone, owning_recruiter_id.
All three arrays carry owning_recruiter_id, so a single call gives you the
number, who it belongs to and who to route it to — you never need a follow-up request to
GET /v1/contacts/{id} or GET /v1/companies/{id} just for the
recruiter. The value is the same one those endpoints return for the same record.
Partial numbers
The match is made on the last nine digits, which is what makes it
format-agnostic: 07700 900412, +44 7700 900412 and
(0)7700-900412 all resolve to the same key. If that finds nobody, the
request is retried automatically on the last eight digits, and
match_strategy tells you which pass answered:
match_strategy |
Meaning |
|---|---|
last9 |
Matched on nine digits. The precise case, and what you will see almost always. |
last8_fallback |
Nine digits matched nobody, so eight were used. Also what you get when the number you sent has only eight digits. |
An eight-digit search is therefore supported and will not be rejected. Two things to know about it:
-
It is a weaker match — fewer digits means more numbers can collide —
so
match_strategyis worth logging alongside the result. -
It can never override a precise one. The eight-digit pass only runs when the
nine-digit pass identified nobody at all, so a
last9result is never diluted by it.
match_confidence means exactly the same thing under both strategies: how
many people were identified, not how strong the digit match was. So
last8_fallback with exact means one person was found on eight
digits, and last8_fallback with ambiguous means several were —
which is the case worth handling, because it is more likely on eight digits than on
nine.
Below eight digits there is not enough to search on, and the response is
match_confidence: "invalid" with a reason.
To pull a matched person's full detail, use the candidate_row_id values
under records — each one is a candidates.id you can fetch
directly, and each is also what GET /v1/candidates/{id}/notes and
GET /v1/candidates/{id}/attachments take. Those two calls return the
person's whole history from any one of their records (§6.2),
so one request is enough.
GET /v1/search/phone?number=%2B44%207700%20900412
Authorization: Bearer sk_live_…
{
"data": {
"query": "+44 7700 900412",
"match_key": "700900412",
"match_strategy": "last9",
"match_confidence": "exact",
"candidates": [
{
"person_id": "3f9a0c14-7d52-4811-b3ee-90c117a642d5",
"name": "Priya Raghunathan",
"phone": "+447700900412",
"email": "priya.raghunathan@example.com",
"owning_recruiter_id": "b52f7c81-4a19-4d0e-8b6c-1e2f3a4b5c6d",
"records": [
{
"candidate_row_id": "3f9a0c14-7d52-4811-b3ee-90c117a642d5",
"job_id": null,
"is_primary_record": true,
"stage": null
},
{
"candidate_row_id": "9d7b2e55-0c31-4f88-a2d6-77bc41e0f3a9",
"job_id": "e41c8a06-9b2d-4c7f-8e10-5a6b7c8d9e0f",
"is_primary_record": false,
"stage": "Client Interview"
}
]
}
],
"contacts": [],
"companies": []
},
"request_id": "req_1786371740115_e8c3427f"
}
An eight-digit search, or a longer number that nine digits could not place. The result
is a normal match — note match_strategy, and that match_key is
the eight-digit key that was used:
{
"data": {
"query": "22 014 088",
"match_key": "22014088",
"match_strategy": "last8_fallback",
"match_confidence": "exact",
"candidates": [
{
"person_id": "6b1f8d03-2a45-4c9e-b7d8-3e4f5a6b7c8d",
"name": "Tomasz Wierzbicki",
"phone": "22 014 088",
"email": "t.wierzbicki@example.com",
"owning_recruiter_id": "d0f3b7a2-5e61-4c92-8a03-4b5c6d7e8f90",
"records": [
{
"candidate_row_id": "6b1f8d03-2a45-4c9e-b7d8-3e4f5a6b7c8d",
"job_id": null,
"is_primary_record": true,
"stage": null
}
]
}
],
"contacts": [],
"companies": []
},
"request_id": "req_1786371740882_a41d97c6"
}
A number with fewer than eight digits cannot be matched. Note this is still
200 OK — the request was well-formed, the input simply carried too little
information:
{
"data": {
"query": "900412",
"match_confidence": "invalid",
"reason": "fewer than 8 digits — not enough to match on",
"candidates": [],
"contacts": [],
"companies": []
},
"request_id": "req_1786371741663_72b0d9ae"
}
GET/v1/search/email #
The email counterpart. Matching is exact on the full address and case-insensitive.
| Parameter | Type | Default | Notes |
|---|---|---|---|
address |
string | — | Required. email and q are accepted as aliases. |
limit |
integer | 100 | Maximum 100. Applies to the candidates array. |
Inside data: query,
match_confidence (exact, ambiguous or
none), candidates and contacts. There is no
companies array, no match_key and no
match_strategy — an address is matched whole, so there is no partial-match
pass to report. Entries in candidates carry person_id,
name, email, phone,
owning_recruiter_id and records; each record here carries
candidate_row_id, job_id and is_primary_record —
no stage. Entries in contacts carry id,
name, email, phone, company_id,
owning_recruiter_id.
As with phone search, owning_recruiter_id is on both arrays, so identifying
the sender and knowing who owns them is one request. It is the same value
GET /v1/contacts/{id} returns for that contact.
match_confidence follows the same rule as phone search (§6.3), counting people and client contacts. An address is never invalid: an
address we do not hold is none. A plus-addressed mailbox such as
jane+finance@example.com is matched correctly whether the + is
sent raw or percent-encoded.
GET /v1/search/email?address=priya.raghunathan%40example.com
Authorization: Bearer sk_live_…
{
"data": {
"query": "priya.raghunathan@example.com",
"match_confidence": "exact",
"candidates": [
{
"person_id": "3f9a0c14-7d52-4811-b3ee-90c117a642d5",
"name": "Priya Raghunathan",
"email": "priya.raghunathan@example.com",
"phone": "+447700900412",
"owning_recruiter_id": "b52f7c81-4a19-4d0e-8b6c-1e2f3a4b5c6d",
"records": [
{
"candidate_row_id": "3f9a0c14-7d52-4811-b3ee-90c117a642d5",
"job_id": null,
"is_primary_record": true
},
{
"candidate_row_id": "9d7b2e55-0c31-4f88-a2d6-77bc41e0f3a9",
"job_id": "e41c8a06-9b2d-4c7f-8e10-5a6b7c8d9e0f",
"is_primary_record": false
}
]
}
],
"contacts": []
},
"request_id": "req_1786371743290_1af5e60c"
}
GET/v1/counts #
Record counts for all nine entities in one request. Use it to reconcile a completed sync
without re-walking the pages. These are always whole-entity totals, never filtered by
updated_since — for a filtered figure, read total_count from
the first page of a list request.
| Parameter | Type | Default | Notes |
|---|---|---|---|
entities |
csv | all nine |
Count only these entities. Comma-separated, any of the nine names. An unknown name
is a 400 BAD_REQUEST.
|
GET /v1/counts
Authorization: Bearer sk_live_…
{
"data": {
"candidates": 1184,
"applications": 327,
"contacts": 2043,
"companies": 611,
"jobs": 148,
"notes": 5926,
"tasks": 214,
"attachments": 1477,
"users": 19
},
"request_id": "req_1786371745008_c6d7031b"
}
These are exact counts under the same rules the list endpoints use, so
data.candidates equals the total_count from an unfiltered
GET /v1/candidates. Counts are point-in-time; one taken before a sync will
not match one taken after if the data changed in between.
Ask for what you need. Every count is exact, which means each one is a
real pass over the data — the full nine-entity response is the most expensive call in
the API, and notes is the most expensive of the nine. If you are
reconciling one entity, request one:
GET /v1/counts?entities=notes
Authorization: Bearer sk_live_…
{
"data": { "notes": 5926 },
"request_id": "req_1786371745008_c6d7031b"
}
The response contains exactly the entities you asked for, so a caller that requested
notes gets a one-key object rather than nine keys with eight nulls.
Duplicates in the list are collapsed. If a nightly reconciliation genuinely needs all
nine, ask for all nine — that is what the default is for. The parameter exists so a
per-minute health check does not have to.
data.candidates counts records, not people — see
§6.1.
6. Three behaviours to build around #
These three are the ones an integrator most often misses, and each one leads somewhere expensive if it is missed. They are worth reading before you write any code against the endpoints above.
6.1 A candidate record is a candidate's involvement, not just the person #
This is the single most important thing to understand about candidates.
A person has one main record, plus
one additional record for each job they are put forward for. Each of
those records has its own id, its own stage, and its own
updated_at. So a person you have shortlisted for three roles appears as
four candidates records.
Two fields make this tractable:
What follows from that:
| To do this | Do this |
|---|---|
| Count people | Group on person_id and count the distinct values. |
| Count records |
Read total_count.
total_count counts records, not people.
|
| Build a person in your system |
Group the records by person_id; take the person's attributes from
the record where is_primary_record is true.
|
| Build the pipeline |
Use the records where is_primary_record is false — or
use GET /v1/applications, which is exactly that set with
pipeline-shaped fields.
|
| Address a specific involvement |
Use that record's id. Every record is individually addressable.
|
Personal attributes — name, phone, email, salary, consent — are carried on every record belonging to a person, so you never have to make a second request to complete a record. The main record is the one to trust when two disagree.
We expose the records rather than collapsing them for you because collapsing loses
information no consumer can reconstruct: which stage on which job, and which record a
given note or file was filed against. person_id gives you the collapse
deterministically, on your own terms.
GET /v1/search/phone returns one entry per person, with
that person's records nested under records. When a receptionist is
deciding who is on the line, three rows for one human is a wrong answer.
GET /v1/search/email behaves the same way. These two endpoints are the
only place the API collapses to people.
The person_id those endpoints return is the same
person_id as on GET /v1/candidates, so you can join straight from a search result to the records you have already
synced — no second lookup by name or number. Within a search result, each entry under
records carries a candidate_row_id, and that remains the
exact identifier of the individual record: person_id identifies the
human, candidate_row_id identifies one of their records.
6.2 Notes and attachments for a candidate are returned for the whole person #
When you call GET /v1/candidates/{id}/notes or
GET /v1/candidates/{id}/attachments — or the same paths under
/v1/applications/ — you get everything belonging to that
person, across all of their records, not only the items filed against
the id you passed.
This is deliberate. Notes and files attach to whichever record happened to receive them: a screening note written before anyone was shortlisted sits on the main record, while interview feedback sits on the job-linked one. A CV uploaded once is attached to a single record. If these endpoints answered only for the exact id you passed, then a candidate opened from a job would look as though they had no history at all — and "no history" is a confident wrong answer, not a visibly incomplete one.
So: asking any one of a person's records returns their complete history. The practical consequences:
Contacts, companies and jobs have no equivalent grouping: for those, notes and attachments are returned for exactly the record you asked about.
6.3 Phone matching is format-agnostic and confidence-scored #
Format does not matter. 07700 900412,
+447700900412, (0)7700-900412 and
+44 7700 900412 all match the same stored number, and they match it whether
the number was stored nationally or in international form. Matching is done on a
normalised key derived from the last nine digits, so you can pass whatever your
telephony gives you without pre-processing it. The key we used is returned as
match_key for diagnostics, and match_strategy says how many
digits it represents.
Partial numbers are supported, on a second pass. If nine digits
identify nobody, the search is retried on the last eight, and
match_strategy reports which pass produced the answer (last9
or last8_fallback). This is also what handles a number you send with only
eight digits, and it is what makes short stored numbers findable at all — a record held
with fewer than nine digits could not previously be reached by this endpoint under any
query.
The order is not arbitrary. The eight-digit pass runs only when the
nine-digit pass found nobody, so it can never dilute or override a precise match. And
match_confidence keeps its exact meaning under both — it counts people, not
digits — so an eight-digit hit on one person is exact, and on several is
ambiguous. Because eight digits collide more readily than nine,
ambiguous is the more likely outcome there; handle it the same way, just
expect it more often.
Below eight digits there is nothing to search on. Rather than returning
a wide guess, we return match_confidence: "invalid" with an empty result
set and a reason.
How match_confidence is scored
Count the people. Add up the entries in candidates (one
entry per person) and contacts (client contacts, who are also
people). That count alone decides the value.
companies is not counted — see the notes below the table
for why.
| Value | When | What the caller should do |
|---|---|---|
exact |
Exactly one person matched — one candidate, or one client contact. A matching company alongside that one person does not change this. | Proceed. Check which array the person is in, then route the call. |
ambiguous |
Two or more people matched, in any combination: several candidates, or a candidate and a client contact, and so on. | Do not auto-resolve. Present the choice. |
none |
No person matched — even if a company did.
companies may still be populated; see the note below.
|
Treat as an unknown caller, but read companies first. |
invalid |
Fewer than eight digits. Phone search only. | Do not treat as "unknown" — the input was insufficient, not the data. |
One person plus a matching company is exact, not
ambiguous.
This is the case most integrations will hit, and the easy one to misread. A company's
main number is, in practice, nearly always the direct number of one of that company's
own contacts rather than a separate switchboard line — so when a company matches
alongside a person, the two are usually the same human reached two ways.
Counting the company as a second match would report ambiguous for a
caller we had in fact identified, and a voice receptionist reading
ambiguous as "stop and ask who is calling" would interrogate someone it
already knew. So companies are returned as context and never scored.
companies can be populated while match_confidence is
none.
That combination is not a contradiction and not an error. It means the number is
recognised as belonging to a company, but no individual could be identified from it.
Treat it as useful context — you can tell the caller which organisation the number
belongs to — rather than as a match. Do not route it to a person.
exact does not mean "a candidate matched". A number that
resolves to exactly one client contact and no candidate is also exact.
Always look at which array the person is in before deciding what kind of caller you
have.
ambiguous is the case to get right. It means more than one
person genuinely shares that number — a shared household or office line, a main company
number entered as somebody's personal number, a family member's mobile. The digit key is
a pre-filter, not proof of identity, and picking the first entry would silently
attribute a call to the wrong person and file the resulting note under the wrong human.
Return the choice to whoever is handling the call and let them pick. Everything needed
to render that choice — name, owning_recruiter_id, and the roles under
records — is already in the response, in the candidates and
contacts arrays.
The rule in one line:
exact is the only value that licenses an automatic decision.
Treat ambiguous as a question and none as an unknown caller.
GET /v1/search/email uses the same vocabulary, minus invalid,
scored over people and client contacts, and the same rule applies:
ambiguous is a question, not a result.
7. Known limitations in v1 #
Stated plainly so you do not build around behaviour that is not there.
One email address and one phone number per candidate and per contact
emails is an array because the underlying model will hold more than one in
a later version, and shipping it as an array now means your client will not need to
change. Today it contains zero or one entry. mobile exists on
candidates and contacts and is always null —
it is declared rather than omitted so the field is visibly unsupported instead of
looking like data that failed to arrive. The single number is in phone,
with the form it was originally entered in available as phone_as_entered.
users[].external_id is always null in v1
The field exists for joining our users to your own directory. Nothing populates it yet.
Match on email in the meantime.
v1 is read-only
Every endpoint is a GET, and any other method returns
405 METHOD_NOT_ALLOWED together with an Allow header listing
what is accepted.
Creating a note, creating a task, and updating any field are not in v1.
There is no write endpoint to discover, and no scope that enables one. Plan a one-way
flow out of Shortlists for now.
Private notes and private tasks are excluded
A note or task that a recruiter marked private in Shortlists is
never returned by this API. That applies everywhere —
GET /v1/notes, GET /v1/tasks, fetching one by id, the
per-record …/notes sub-resource, and the counts — so a private item never
appears in data and is never included in total_count or in
GET /v1/counts. The figures stay internally consistent: what you can count
is what you can fetch. Fetching a private item directly by its id returns
404 NOT_FOUND.
The practical consequence: a note that a recruiter can see in Shortlists but that never arrives over the API is almost always a private one, not a sync fault. Nothing you receive from this API needs a visibility check applied on top of it — the filtering has already happened.
This is not a setting on your key and there is no self-service way to change it. Including private notes requires an administrative grant made by Shortlists, and it is not offered as part of a standard API key — so build on the assumption that private items never arrive.
updated_at on a record that was never edited reflects when it was created
It is never null, and it is safe to use as the sole sync watermark — but do not read it
as evidence that the record was modified. For a record that has never changed,
updated_at equals created_at.
And the converse does not hold either: records edited before 2026-08-13 report
updated_at equal to created_at.
Change tracking on several record types begins on that date; the true last-modified time
of anything edited earlier was not recorded and cannot be recovered. So treat your first
sync as a full baseline: updated_at == created_at means "no change
recorded", never "never edited", and no change history before 2026-08-13 can be
inferred from this field. From that date forward, every edit moves
updated_at reliably.
Some attachments reference files that were not transferred
Where a workspace was populated by migrating from a previous system, a proportion of
attachment records describe a file whose bytes never arrived. The attachment still
appears in GET /v1/attachments and in the per-record list, because the
record of the file existing is itself useful, but
GET /v1/attachments/{id}/download for one of those returns
404 NOT_FOUND.
A 404 from download is therefore a legitimate outcome, not necessarily a
fault
— treat it as "no file available for this attachment" and carry on rather than retrying
or failing the run.
Notes are the modern recruiter notes
Meeting transcripts and meeting summaries are a separate domain and are not exposed as
notes in v1.
Archived records remain readable
An archived company or job keeps its id, keeps appearing in the sync
stream, and carries is_archived: true. Filter on that flag rather than
expecting archived records to disappear.
8. Integration checklist #
A sequence that works, and the reasons behind it:
-
Baseline. For each entity, walk
GET /v1/{entity}?limit=500untilnext_cursorisnull. Recordtotal_countfrom the first page. -
Reconcile. Compare your row count against
total_count, and againstGET /v1/counts. Forcandidates, remembertotal_countcounts records; your people count will be lower. -
Store the watermark. Keep the
updated_atof the last record of each entity's run. -
Increment. Re-run each entity with
updated_since=<watermark>. Upsert onid— the inclusive comparison means you will see the boundary record again. -
Retry every
5xx, with exponential backoff and jitter. Not only429and503. Under sustained parallel load you should expect the occasional502or504from the network layer in front of the API — measured at 11 of roughly 598 requests over a 45-minute window in our own testing. Those never reached the API itself, so they are not a sign that anything is wrong with your request or with your data, and every one of them succeeded on retry.Back off exponentially and add jitter: a fleet of sync workers that all retry on the same fixed schedule re-converges into the same spike that caused the first failure. Something like
min(32s, 2^attempt) ± up to 50%over 5 attempts is ample.For
429— and for503— honour theRetry-Afterheader rather than guessing. It is in seconds, and it is the actual time until your rate-limit window resets; sleeping less just spends another request on the same refusal, and sleeping much more costs you throughput you are entitled to. Use your backoff schedule only when there is noRetry-Afterto read.Prefer
limit=500over more concurrency. -
Do not assume a failure has a JSON body. Every response the API
itself generates carries
{ "error": { "code", "message" }, "request_id" }, and you should logrequest_idon those and quote it when reporting a problem. But a5xxraised by the network layer in front of the API never reached our code, so it has no JSON envelope and norequest_id— it may be an HTML error page or empty. Parse defensively: a body you cannot decode is itself a retryable signal, not a bug in your client. For downloads, the302carries its request id in theX-Request-Idheader instead of a body. -
Files on demand. Store attachment
ids, not signed URLs. Mint a fresh redirect when a file is actually needed, and treat a404as "no file available". A fetch of a signed URL can also fail withNoSuchKeyeven though the redirect succeeded — that means the file is gone, not that the API is unwell. Record it as unavailable and do not retry it. -
Live lookup. Point your telephony at
GET /v1/search/phoneand pass the caller ID through unmodified. Branch onmatch_confidencebefore doing anything with the result, and never auto-resolveambiguous.