Developer Docs
Get an API key
REST API · Version 1

Shortlists REST API

Read and write access to your recruiting data over HTTP.

v1 · live Read + write GET · POST · PATCH · DELETE 600 req / min

About this version #

A read and write 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. 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.

Writes are live

Write access shipped in v1 — create and update notes and tasks, and PATCH a record's allow-listed fields (see §5.9–§5.11). If a field or behaviour is awkward to consume, or there is a particular write you need most, tell us early: that feedback still shapes what comes next while it is 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, POST, PATCH and DELETE. See §5.

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_…
  • One key per workspace. The key determines which workspace's data you see. There is no other scoping parameter, and no request can reach data outside that workspace.
  • Keys are created by a workspace owner or an administrator, from inside Shortlists. The full key value is shown once, at the moment it is created; only a hash of it is retained afterwards, so a lost key is replaced rather than recovered. A key can be revoked at any time, and revocation takes effect on the next request.
  • Keys carry scopes. Every key carries the read scope; a key may additionally carry notes:write, tasks:write and/or records:write. Scopes are deny-by-default and chosen when the key is created.
  • A key stops working if the person who created it loses access to the workspace, whether or not the key itself was revoked.
  • Send the key from your server only. It is not safe in a browser, a mobile binary, or any client you do not control.
  • A missing, malformed, unknown or revoked key returns 401 UNAUTHORIZED.
  • A key that is valid but lacks the scope an endpoint requires returns 403 FORBIDDEN_SCOPE.

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 digits250 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"
}
  • next_cursor is null on the last page. That is the only end-of-sequence signal — loop until it is null, and do not infer the end from anything else.
  • A page that fills limit exactly always carries a cursor, so when the number of records is an exact multiple of limit your run ends with one final empty page (data: [], next_cursor: null). That is normal, not an error.
  • cursor is an opaque token. Do not parse it, construct it, or carry it across a change of updated_since. Its internal format is not part of this contract. A cursor we cannot decode returns 400 BAD_REQUEST.

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 is not supported there; 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.

Log this

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

  • id — string, stable for the life of the record. Treat it as opaque; do not assume a format. (attachments ids are deliberately prefixed — see §4.8.)
  • Timestamps — ISO 8601 with timezone, for example 2026-08-12T14:22:09.311Z. Nullable unless stated.
  • owning_recruiter_id — the users.id of the recruiter who owns the record. How it is derived differs by entity, so do not assume one rule across the API:
    • On candidates, and on the candidates array of both search endpoints: the owner explicitly recorded against the record, if that person is still an active member of the team; otherwise the recruiter assigned to the role, again only if they are still an active member; otherwise null. It is never the account that imported the record.
    • On jobs: the assigned recruiter, falling back to whoever created the job.
    • On contacts and companies, and on the contacts and companies arrays of the search endpoints: the owner stored against the record, as it stands.
    The rule is per entity, not per endpoint — a contact returned by GET /v1/search/phone carries the same owning_recruiter_id as the same contact returned by GET /v1/contacts/{id}, and the two cannot disagree. Whichever route you reach a record by, the value is the same. For records that arrived by bulk migration this is usually null, and that is on purpose: it is left null rather than guessed. A confidently wrong name is worse than an honest null, especially when the value is used to route a live call. Fall back to your own routing rules when it is null rather than treating it as an error.

    State of your data at go-live — plan for this before building call routing on the field: in an account whose records arrived by bulk migration, owning_recruiter_id is null on every candidate record on day one — the source system's ownership was not carried over, no role assignments exist yet to fall back to, and we do not substitute the import account. The field is present and correctly wired; it populates as records are owned or roles are assigned in the app, and how existing records get owners is an onboarding conversation, not something the API infers. Until then, a routing integration must expect null on this field for every candidate.
  • Any field may be null unless the notes say otherwise. Real recruiting data is sparse.

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
Read this before filtering roles

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.

Read before using 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
Attachment ids are prefixed

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

  • Do not send your API key to the Location URL. It is already signed, and the signature is the authorisation.
  • Do not store the signed URL. It expires in 60 minutes. Store the attachment id and request a fresh redirect when you next need the file.
  • An unknown attachment id, or one belonging to another workspace, returns 404 NOT_FOUND.
  • An attachment whose stored location cannot be resolved at all returns 404 NOT_FOUND.
  • What a non-404 does and does not promise. A 200/302 means we resolved where the file should be stored and issued a valid, correctly-signed URL for that location. It does not guarantee the bytes are still there: fetching the URL can still fail, and your client must handle that.

    The reason is that the location and the file are tracked separately. Storage can hold a record saying an object exists — with a size and a content type — while the underlying object has since gone. Signing does not read the object, so a URL for a missing file is signed just as successfully as one for a present file, and there is no check we can add at our end that would tell them apart: the record we would be checking is the thing that is wrong.

    Treat a storage-level failure as a missing file, not as an API error. Concretely, a fetch of the signed URL that returns an error body of the form

    { "statusCode": "404", "error": "Not found", "message": "The resource was not found", "code": "NoSuchKey" }

    means the file is gone. Record the attachment as unavailable and carry on with the rest of the sync — do not retry it, and do not treat it as an outage. Note that this arrives with an HTTP 400 whose JSON body carries "statusCode": "404", so branch on the body's code field (NoSuchKey) rather than on the HTTP status. A missing file is not a symptom of anything being broken and it will not fix itself on a retry.

    If you need to report one to us, quote the X-Request-Id header from the 302 response.

  • The 302 response carries an X-Request-Id header. A redirect has no JSON body, so this is where its request id lives — quote it if you need to report a problem with a download.
  • An id that is not of the form <kind>:<identifier>, that carries a prefix other than the four listed in §4.8, or that contains an invalid percent-escape, returns 400 BAD_REQUEST.

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_strategy is 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 last9 result 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.

Joining back to the rest of the API

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.

POST/v1/notes #

Create a note on a record. Requires the notes:write scope. For a candidate the note resolves to the canonical person, so it appears wherever that candidate is opened (see §6.2). Pass a reference to make the call idempotent — repeating it with the same reference updates that note instead of creating a second one.

FieldTypeRequiredNotes
parent_typestringyesOne of candidate, contact, company, job.
parent_iduuidyesThe record the note belongs to.
body_htmlstringyesThe note body; HTML is accepted.
titlestringnoOptional heading.
typestringnoA free label, e.g. call.
author_emailstringnoCredits an accepted workspace member by exact work email; otherwise the workspace owner.
is_privatebooleannoMust be true or false. Private notes are never returned by this API (§7).
referencestringnoYour idempotency key. A repeat with the same reference updates, never duplicates.
POST /v1/notes
Authorization: Bearer sk_live_…
Content-Type: application/json

{
  "parent_type": "candidate",
  "parent_id": "3f9a0c14-7d52-4811-b3ee-90c117a642d5",
  "body_html": "Spoke with the candidate — keen on the role.",
  "reference": "crm-note-8842"
}
{
  "data": { "id": "a1b2c3d4-…" },
  "request_id": "req_…"
}

Update or delete a note

PATCH /v1/notes/{id} updates any of body_html, title, type or is_private; an unknown field or an empty body is a 400. DELETE /v1/notes/{id} removes the note and returns { "data": { "deleted": true } }; deleting an already-gone note is a 404. Both require notes:write.

POST/v1/tasks #

Create a follow-up task on a record. Requires the tasks:write scope. Idempotent on reference, exactly like notes.

FieldTypeRequiredNotes
parent_typestringyesOne of candidate, contact, company. Tasks do not attach to jobs.
parent_iduuidyesThe record the task belongs to.
titlestringyesThe task title.
body_htmlstringnoOptional detail.
due_datestringnoA date, YYYY-MM-DD. An unparseable value is a 400.
owner_emailstringnoAssigns to an accepted workspace member by exact work email.
is_privatebooleannoMust be true or false.
referencestringnoIdempotency key.
POST /v1/tasks
Authorization: Bearer sk_live_…
Content-Type: application/json

{
  "parent_type": "candidate",
  "parent_id": "3f9a0c14-7d52-4811-b3ee-90c117a642d5",
  "title": "Send the updated CV to the client",
  "due_date": "2026-09-01"
}

Update or delete a task

PATCH /v1/tasks/{id} updates any of title, body_html, due_date, owner_email, is_private, or status (open or completed; the completion timestamp is kept in step for you). DELETE /v1/tasks/{id} removes it. Both require tasks:write.

PATCH/v1/{entity}/{id} #

Update a narrow, explicit set of fields on a record. Requires the records:write scope. Only the fields below are writable per entity; any other field is refused with a 400. Numeric fields must be JSON numbers (zero or greater); currencies are validated; owning_recruiter_id and company_id must resolve inside the same workspace. Pipeline stage/status and consent flags are intentionally not writable.

EntityWritable fields
candidatesname, email, phone, job_title, current_company, current_salary_amount, current_salary_currency, expected_salary_amount, expected_salary_currency, owning_recruiter_id
contactsname, email, phone, company_id, owning_recruiter_id
companiesname, phone, owning_recruiter_id
jobstitle, salary_amount, salary_currency, company_id, live, owning_recruiter_id
PATCH /v1/candidates/3f9a0c14-7d52-4811-b3ee-90c117a642d5
Authorization: Bearer sk_live_…
Content-Type: application/json

{
  "expected_salary_amount": 85000,
  "expected_salary_currency": "GBP"
}

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:

  • person_id — the same value on every record belonging to one human.
  • is_primary_recordtrue on the one main record, false on the job-linked ones.

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.

The exception

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:

  • The parent_id on a returned note or attachment is often not the id you requested. It is the record the item is actually filed against. Both belong to the same person.
  • Requesting notes for two records of the same person returns the same set twice. Deduplicate on the note's or attachment's own id if you fan out across records.
  • The most efficient way to fetch a person's history is one request against any one of their records, not one request per record.

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.
Three consequences of that scoring

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.

Writing data

v1 is no longer read-only: it accepts POST, PATCH and DELETE on notes and tasks, and PATCH on a record's allow-listed fields — see §5.9–§5.11. Write access is per key and deny-by-default: a key carries read plus any of notes:write, tasks:write and records:write. Two things to hold in mind: numeric fields such as salary amounts must be sent as JSON numbers, not strings, and a wrong-typed or out-of-range value is rejected with 400 rather than being coerced; to clear a field, send null, not an empty string. Pipeline stage/status and the consent / right-to-represent flags are deliberately not writable.

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:

  1. Baseline. For each entity, walk GET /v1/{entity}?limit=500 until next_cursor is null. Record total_count from the first page.
  2. Reconcile. Compare your row count against total_count, and against GET /v1/counts. For candidates, remember total_count counts records; your people count will be lower.
  3. Store the watermark. Keep the updated_at of the last record of each entity's run.
  4. Increment. Re-run each entity with updated_since=<watermark>. Upsert on id — the inclusive comparison means you will see the boundary record again.
  5. Retry every 5xx, with exponential backoff and jitter. Not only 429 and 503. Under sustained parallel load you should expect the occasional 502 or 504 from 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 for 503honour the Retry-After header 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 no Retry-After to read.

    Prefer limit=500 over more concurrency.

  6. Do not assume a failure has a JSON body. Every response the API itself generates carries { "error": { "code", "message" }, "request_id" }, and you should log request_id on those and quote it when reporting a problem. But a 5xx raised by the network layer in front of the API never reached our code, so it has no JSON envelope and no request_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, the 302 carries its request id in the X-Request-Id header instead of a body.
  7. Files on demand. Store attachment ids, not signed URLs. Mint a fresh redirect when a file is actually needed, and treat a 404 as "no file available". A fetch of a signed URL can also fail with NoSuchKey even 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.
  8. Live lookup. Point your telephony at GET /v1/search/phone and pass the caller ID through unmodified. Branch on match_confidence before doing anything with the result, and never auto-resolve ambiguous.