Skip to content

Mirror candidates

Copy applicants and sourced candidates into your system.

This guide copies the people in a Tahoe workspace into your own system: the applicants who applied to your customer’s jobs and the candidates their team found and saved. Of all integrations, this one carries the most obligations, so the guide also shows how to avoid collecting more than you need and how not to mistake a withheld field for an empty one.

Two kinds of people

ApplicantsSourced profiles
Endpoint/applicants/sourced-profiles
Scopeapplicants:readsourced_profiles:read
How they arrivedThey applied to a job, or came in from the customer’s ATSSomeone on the team found and saved them
provenance.originapplicant, or ats_importsourced
Consent basiscandidate_submitted (ATS imports: customer_provided)legitimate_interest_sourcing
Shown a notice by TahoeYes, when they applied through TahoeNo
Contact detailsWhat they submitted. Never behind a paywall.Only what the workspace already revealed. Reading them never spends credits.

Keep these as separate tables, or at least carry provenance.origin on every row. Merging them into one “candidates” table loses the difference that decides what you may do with each person, and it is the first thing anyone will ask you about.

Build the copy

Copy structure first

Backfill without personal data
import os

import requests

BASE = "https://tahoe.workonward.com/api/partner/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['TAHOE_API_KEY']}"


def backfill_applicants(store):
    cursor = None
    while True:
        params = {"limit": 100}
        if cursor:
            params["cursor"] = cursor
        response = session.get(f"{BASE}/applicants", params=params, timeout=30)
        response.raise_for_status()
        body = response.json()

        for applicant in body["data"]:
            provenance = applicant["provenance"]
            store.upsert_person(
                tahoe_id=applicant["id"],
                # "applicant" or "ats_import" here; "sourced" on /sourced-profiles.
                origin=provenance["origin"],
                full_name=applicant["full_name"],
                location=applicant["location"],
                linkedin_url=applicant["linkedin_url"],
                # Kept so a compliance question can be answered without
                # another API call.
                consent_basis=provenance["consent"]["basis"],
                unsubscribed=provenance["consent"]["unsubscribed"],
                external_refs=applicant["external_refs"],
                # No contact details here. They are fetched when needed.
                contact_state="unknown",
            )

        cursor = body["next_cursor"]
        if not cursor:
            return

Nothing in that loop touches the personal-data budget. You now know who exists, where each person came from, and how to find them in your own system. Page /sourced-profiles the same way. Take a change-feed watermark before you start, as described in Incremental sync.

Match people to your own records

When an applicant came from an ATS, external_refs holds their ID in that system. Match on system plus id and you are done: no name matching, no guessing.

For everyone else, resolve by LinkedIn URL or email, up to 100 at a time.

Resolve a hundred at a time
def reconcile(store):
    """Match up to 100 of our own records to Tahoe people in 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)
    ]
    response = session.post(f"{BASE}/people/resolve:batch", json={"keys": batch}, timeout=30)
    response.raise_for_status()
    body = response.json()

    # Results come back IN ORDER, unmatched ones included, so pairing them
    # with the input by position is safe and nothing goes missing.
    for sent, result in zip(batch, body["data"]):
        if not result["matched"]:
            store.mark_unresolved(sent, reason=result["reason"])
            continue

        person = result["person"]
        if person["counts"]["exact_matches"] == 0:
            # Matched on email alone: probable, not certain. Ask a human.
            store.queue_for_review(sent, person["canonical_id"])
            continue

        # canonical_id, not id: the same human found by email and by LinkedIn
        # URL has two ids but one canonical_id.
        store.link_person(sent, person["canonical_id"])

Fetch contact details when you need them

This one decision does most to keep an integration inside its daily personal-data budget.

Fetch on demand
def contact_for(store, applicant_id):
    """Fetch contact details at the moment a recruiter opens the record."""
    cached = store.contact(applicant_id)
    if cached and cached.fresh:
        return cached

    response = session.get(f"{BASE}/applicants/{applicant_id}/contact-info", timeout=30)
    response.raise_for_status()
    body = response.json()

    # "restricted" means Tahoe holds the value and this key may not read it.
    # Storing an empty value would record "this person has no phone", and
    # nothing would make you look again once the scope is granted.
    withheld = set(body.get("restricted") or [])

    store.save_contact(
        applicant_id,
        emails=None if "emails" in withheld else body["emails"],
        phones=None if "phones" in withheld else body["phones"],
        withheld=sorted(withheld),
        field_states=body["field_states"],
        # Never gated by scopes. If true, do not contact this person.
        unsubscribed=body["unsubscribed"],
    )
    return store.contact(applicant_id)
What you seeStore it asNever as
A value in emails or phonesThe value
field_states says not_foundTahoe has none
Named in restrictedUnknown: withheldEmpty or null

Check resume access every time

A resume that came with an application is free to view and download for 90 days after the application, view-only until day 120, and then locked until someone in the workspace unlocks it in Tahoe with Unlock resume (50 credits, once, and permanent). The API cannot unlock a resume.

So access can change with nothing happening on your side. Read resume_access (state, can_view, can_download) each time instead of caching it, and listen for resume.access_changed. Otherwise your screen offers a download that fails with 403 resume_locked.

A download link expires after five minutes and works for one file. Store the resume’s handle, ask for a fresh link when you need the file, and never store the link.

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 you emailing someone who asked
    # not to be contacted.
    "applicant.unsubscribed": mark_unsubscribed,

    "sourced_profile.created": reread_sourced,
    "sourced_profile.updated": reread_sourced,
    "sourced_profile.deleted": purge_sourced,
    # Names the fields that were revealed, never the values. Drop your
    # cached contact details and fetch them again when they are needed.
    "sourced_profile.contact_info_revealed": invalidate_contact_cache,

    "resume.access_changed": reread_resume_access,

    # A handle you stored is no longer the canonical one for this person.
    "person.canonical_id_changed": remap_person,

    # An instruction, not information. See the deletion guide.
    "data_subject.suppression_applied": on_data_subject_event,
    "data_subject.erasure_completed": on_data_subject_event,
}

What you agree to

  • licence.redistributable is always false. Use this data inside your customer’s hiring work. Do not build a directory from it, resell it, or feed it into a product other people search.
  • Honor unsubscribes and erasures. See Deletion and erasure.
  • Do not export in bulk. Paging stops at 10,000 rows per list (400 result_window_exceeded). Incremental sync is the supported path, and it costs less.
  • Keep applicants and sourced profiles apart in your own store, for as long as you hold them.