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

ApplicantsSourced profiles
Endpoint/applicants/sourced-profiles
Scopeapplicants:readsourced_profiles:read
How they arrivedThey applied to your customerA recruiter found them
Consent basiscandidate_submittedlegitimate_interest_sourcing
Saw a notice?YesNo
Contact dataCandidate-submitted, never paywalledOnly 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

Backfill without personal data
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:
            return

Nothing 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

Resolve a hundred at a time
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.

Hydrate on demand
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 seeStore it asNot as
In emails / phonesThe 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

The events that matter here
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.redistributable is 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.