Guides
Mirror candidates
Copying people into your own database is the integration with the most obligations attached. This is how to do it without collecting more than you need or caching a withheld field as empty.
Two populations, different obligations
| Applicants | Sourced profiles | |
|---|---|---|
| Endpoint | /applicants | /sourced-profiles |
| Scope | applicants:read | sourced_profiles:read |
| How they arrived | They applied to your customer | A recruiter found them |
| Consent basis | candidate_submitted | legitimate_interest_sourcing |
| Saw a notice? | Yes | No |
| Contact data | Candidate-submitted, never paywalled | Only what the workspace already revealed |
Treat these as different tables, or at least carry provenance.origin on every row. Merging them into one “candidates” table loses the distinction that determines what you may do with each — and it is the first thing anyone will ask you about.
1. Mirror structure first
def backfill_applicants(session, store):
cursor = None
while True:
params = {"limit": 100}
if cursor:
params["cursor"] = cursor
body = session.get(f"{BASE}/applicants", params=params, timeout=30).json()
for applicant in body["data"]:
store.upsert_person(
tahoe_id=applicant["id"],
origin=applicant["provenance"]["origin"],
full_name=applicant["full_name"],
location=applicant["location"],
linkedin_url=applicant["linkedin_url"],
# Carried through so a compliance question has an answer that
# does not require re-reading the API.
consent_basis=applicant["provenance"]["consent"]["basis"],
unsubscribed=applicant["provenance"]["consent"]["unsubscribed"],
external_refs=applicant["external_refs"],
# Contact details are NOT fetched here. See below.
contact_state="unknown",
)
cursor = body["next_cursor"]
if not cursor:
returnNothing in that loop touches the personal-data budget. You now have the structure — who exists, where they came from, and how to reach them in your own ATS.
2. Deduplicate against your own records
Two mechanisms, in order of reliability.
external_refs, when the applicant came from an ATS
An exact identifier in a system you also talk to. Match on system plus id and you are done — no name matching, no guessing.
Identity resolution, for everyone else
def reconcile(session, store):
"""Match our own records to Tahoe people. 100 per call, one call."""
batch = [
{"kind": "linkedin_url", "value": row.linkedin_url}
if row.linkedin_url
else {"kind": "email", "value": row.email}
for row in store.unmatched(limit=100)
]
body = session.post(f"{BASE}/people/resolve:batch", json={"keys": batch},
timeout=30).json()
# Results come back IN ORDER, including the ones that did not match, so a
# positional join is safe and nothing goes silently missing.
for sent, result in zip(batch, body["data"]):
if not result["matched"]:
store.mark_unresolved(sent, reason=result["reason"])
continue
person = result["person"]
# canonical_id, not id: resolving the same human by email and by
# LinkedIn URL yields different `id` values but one canonical_id.
# Keying on `id` produces duplicate people.
store.link_person(sent, person["canonical_id"])3. Fetch contact details lazily
This is the single decision that determines whether your integration fits inside its budget.
def contact_for(session, store, person_id):
"""Fetch contact details at the moment a recruiter opens the record."""
cached = store.contact(person_id)
if cached and cached.fresh:
return cached
body = session.get(f"{BASE}/applicants/{person_id}/contact-info", timeout=30).json()
# `restricted` means "we hold it and you may not have it". Writing null
# here would record "this person has no phone number", and nothing would
# ever tell us to look again once the scope is granted.
withheld = set(body.get("restricted") or [])
store.save_contact(
person_id,
emails=body["emails"],
phones=None if "phones" in withheld else body["phones"],
phones_state="withheld" if "phones" in withheld else "known",
# NEVER gated, whatever scopes we hold. Do not email this person.
unsubscribed=body["unsubscribed"],
field_states=body["field_states"],
)
return store.contact(person_id)| What you see | Store it as | Not as |
|---|---|---|
In emails / phones | The value | — |
Absent, field_states says not_found | "Tahoe has none" | — |
Named in restricted | "unknown — withheld" | empty / null |
4. Resumes: check access every time
resume_access is time-based as well as purchase-based. A resume you could read last week can be locked today with nothing changing at your end, so read can_view and can_download on each access rather than caching an entitlement.
Subscribe to resume.access_changed. Without it, a mirrored copy of the access block goes stale and your UI offers a download that 403s.
Download URLs expire in minutes and are bearer capabilities for one file. Store the handle, request a URL when you need the bytes, and never persist the URL.
5. Keep it current
HANDLERS = {
"applicant.created": reread_applicant,
"applicant.updated": reread_applicant,
"applicant.deleted": purge_applicant,
# Wire this one FIRST. It is what stops us emailing someone who asked
# us not to, and it is the cheapest possible mistake to avoid.
"applicant.unsubscribed": mark_unsubscribed,
"sourced_profile.created": reread_sourced,
"sourced_profile.updated": reread_sourced,
"sourced_profile.deleted": purge_sourced,
# The payload never carries the value — only that new contact data now
# exists. Invalidate the cache; do not try to read a number out of it.
"sourced_profile.contact_info_revealed": invalidate_contact_cache,
"resume.access_changed": reread_resume_access,
# A handle we stored is no longer the canonical one for this human.
"person.canonical_id_changed": remap_person,
# An instruction, not information. See the deletion guide.
"data_subject.suppression_applied": honour_suppression,
}What you are agreeing to
licence.redistributableis always false. Use this inside your customer’s hiring workflow. Do not build a directory, resell it, or feed it into a product other people search.- Honour unsubscribes and suppressions — see deletion and erasure.
- Do not export in bulk. The paging window stops at 10,000 rows for this reason; incremental sync is the supported path and is cheaper anyway.
- Keep sourced and applicant data distinguishable in your own store, for ever.
