본문으로 건너뛰기

후보자 미러링

지원자와 소싱한 후보자를 내 시스템으로 복사하세요.

이 가이드는 Tahoe 워크스페이스의 사람들을 내 시스템으로 복사하는 방법을 설명합니다. 고객의 채용 공고에 지원한 지원자와, 고객의 팀이 찾아 저장한 후보자가 대상입니다. 모든 연동 중 지켜야 할 의무가 가장 많으므로, 필요 이상으로 수집하지 않는 방법과 가려진 필드를 빈 필드로 착각하지 않는 방법도 함께 다룹니다.

두 종류의 사람

지원자소싱한 프로필
엔드포인트/applicants/sourced-profiles
스코프applicants:readsourced_profiles:read
들어온 경로채용 공고에 지원했거나, 고객의 ATS에서 가져왔습니다팀의 누군가가 찾아 저장했습니다
provenance.originapplicant 또는 ats_importsourced
동의 근거candidate_submitted (ATS에서 가져온 경우 customer_provided)legitimate_interest_sourcing
Tahoe의 고지문 표시예, Tahoe를 통해 지원한 경우아니요
연락처본인이 제출한 정보입니다. 크레딧 없이 볼 수 있습니다.워크스페이스가 이미 열람한 것만 제공됩니다. 읽어도 크레딧이 들지 않습니다.

두 그룹은 별도 테이블로 두거나, 최소한 모든 행에 provenance.origin을 함께 저장하세요. 하나의 “후보자” 테이블로 합치면 각 사람에게 무엇을 해도 되는지 결정하는 차이가 사라집니다. 누군가 질문한다면 가장 먼저 물어볼 부분이 바로 이것입니다.

사본 만들기

구조부터 복사하기

개인 데이터 없이 백필하기
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

이 루프의 어떤 부분도 개인 데이터 한도를 쓰지 않습니다. 이제 누가 있는지, 각자 어디서 왔는지, 내 시스템에서 어떻게 찾는지 알 수 있습니다. /sourced-profiles도 같은 방법으로 넘기며 읽으세요. 시작하기 전에 증분 동기화에서 설명한 대로 변경 피드 워터마크를 기록하세요.

내 레코드와 사람 연결하기

ATS에서 온 지원자라면 external_refs에 그 시스템의 ID가 있습니다. system과 id를 함께 맞추면 끝입니다. 이름을 비교하거나 추측할 필요가 없습니다.

그 밖의 경우에는 LinkedIn URL이나 이메일로 한 번에 최대 100명까지 확인(resolve)하세요.

100명씩 확인하기
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"])

연락처는 필요할 때 가져오기

연동이 하루 개인 데이터 한도 안에 머무는 데 이 결정 하나가 가장 큰 역할을 합니다.

필요할 때 가져오기
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)
보이는 것저장할 값이렇게 저장하면 안 됨
emails나 phones에 값이 있음그 값
field_states가 not_foundTahoe에 없음
restricted에 이름이 있음알 수 없음: 가려짐빈 값 또는 null

이력서 접근 권한은 매번 확인하기

지원서와 함께 들어온 이력서는 지원 후 90일 동안 무료로 보고 내려받을 수 있고, 120일째까지는 보기만 가능합니다. 그 뒤에는 워크스페이스의 누군가가 Tahoe에서 Unlock resume을 눌러 잠금을 풀 때까지 잠깁니다(50크레딧, 한 번만, 영구 해제). API로는 이력서 잠금을 풀 수 없습니다.

따라서 내 쪽에서 아무 일이 없어도 접근 권한이 바뀔 수 있습니다. resume_access(state, can_view, can_download)를 캐시하지 말고 매번 읽고, resume.access_changed를 받아 처리하세요. 그러지 않으면 화면에 내려받기 버튼이 보이는데 누르면 403 resume_locked로 실패합니다.

다운로드 링크는 5분 뒤 만료되며 파일 하나에만 쓸 수 있습니다. 이력서의 핸들을 저장해 두고 파일이 필요할 때 새 링크를 요청하세요. 링크 자체는 저장하지 마세요.

최신 상태 유지하기

여기서 중요한 이벤트
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,
}

지켜야 할 약속

  • licence.redistributable은 항상 false입니다. 이 데이터는 고객의 채용 업무 안에서만 쓰세요. 이 데이터로 인물 디렉터리를 만들거나, 재판매하거나, 다른 사람들이 검색하는 제품에 넣지 마세요.
  • 수신 거부와 삭제(소거) 요청을 지키세요. 삭제와 소거를 참고하세요.
  • 대량으로 내보내지 마세요. 페이지 넘김은 목록당 10,000행에서 멈춥니다(400 result_window_exceeded). 증분 동기화가 지원되는 방법이며 비용도 적게 듭니다.
  • 지원자와 소싱한 프로필은 따로 보관하세요. 내 저장소에 보관하는 동안 계속 그렇게 하세요.

관련 문서