공통 규칙
ID, 페이지 나누기, 시간 형식, 가려진 필드, 중복 요청 방지, 이벤트의 출처.
모든 엔드포인트에 공통으로 적용되는 규칙이 몇 가지 있습니다. 객체를 식별하는 방식, 리스트를 페이지로 나누는 방식, 시간을 표기하는 방식, 그리고 API가 일부 정보를 제공하지 않을 때 이를 알리는 방식입니다. 자체 저장소를 설계하기 전에 이 페이지를 한 번 읽어 두세요. 건너뛰었을 때 눈에 띄지 않는 버그로 이어지기 가장 쉬운 부분은 가려진 필드 섹션입니다.
ID
모든 객체에는 job_7Kd2mXq4Rp8v처럼 짧은 접두사와 불투명한 문자열로 이루어진 ID가 있습니다. 접두사를 보면 어떤 종류의 객체인지 알 수 있으므로, ID를 잘못 넣었을 때 쉽게 알아챌 수 있습니다.
| 접두사 | 객체 |
|---|---|
wsp_ | 워크스페이스 |
usr_ | 워크스페이스 사용자 |
job_ | 채용 공고 |
app_ | 지원서 |
apl_ | 지원자(지원한 사람) |
cnd_ | 소싱한 프로필(워크스페이스가 찾아 저장한 후보자) |
pool_ | 공유 인재풀 프로필 |
per_ | 인물(여러 프로필에 걸쳐 연결된 한 사람) |
prj_ | 프로젝트 |
lst_ | 후보자 리스트 |
mem_ | 리스트 멤버십 |
stg_ | 파이프라인 단계 |
res_ | 이력서 |
msg_ | POST /messages로 보낸 메시지 |
upl_ | POST /uploads로 예약한 이력서 업로드 |
ci_ | external_refs 안의 ATS 연결 |
evt_ | 이벤트 |
ers_ | 삭제(소거) 통지 |
pk_ | /me에 표시되는 API 키 |
cur_ | 페이지네이션 커서 |
ID는 객체가 존재하는 동안 바뀌지 않으므로 자체 외래 키로 저장하거나 로그에 남겨도 됩니다. 순차적이지 않고 추측할 수도 없습니다. ID를 직접 만들거나, 범위를 세어 가며 열거하거나, ID로 정렬하지 마세요.
리스트
모든 리스트 엔드포인트는 같은 응답 봉투를 반환합니다.
{
"object": "list",
"data": [
{ "object": "job", "id": "job_7Kd2mXq4Rp8v", "title": "Field Coordinator" },
{ "object": "job", "id": "job_3Hn8vLc2Wq5t", "title": "Site Safety Lead" }
],
"has_more": true,
"next_cursor": "cur_eyJrIjoiMjAyNi0wOC0xNFQwOTowMjoxMVoi..."
}| 필드 | 의미 |
|---|---|
object | 항상 "list"입니다. 각 행에도 자체 object가 있습니다. |
data | 이 페이지의 행. |
has_more | 이 페이지 뒤에 더 있는지 여부. |
next_cursor | 다음 페이지를 받을 때 다시 보낼 위치 값입니다. 리스트를 끝까지 읽었으면 null입니다. |
?limit=로 페이지 크기를 정합니다. 기본값은 25, 최대값은 100입니다. 더 큰 값은 거부되지 않고 100으로 낮춰지며, /me의 rate_limits.max_page_size에서 상한을 확인할 수 있습니다.
커서
페이지는 페이지 번호가 아니라 커서로 이어집니다. ?page=도 ?offset=도 없습니다. 읽는 동안에도 레코드가 추가되고 변경되는데, 움직이는 데이터에 번호를 매긴 페이지를 쓰면 일부 행은 중복되고 일부 행은 빠지기 때문입니다.
커서는 서명되어 있고, 그 커서를 만든 요청(엔드포인트, 필터, 키)에 묶여 있습니다. 쿼리 파라미터의 순서는 상관없고, 페이지 사이에 limit을 바꿔도 됩니다. 그 밖의 경우는 모두 400 invalid_cursor를 반환합니다. 다른 필터와 함께, 다른 엔드포인트에서, 또는 다른 키로 커서를 쓴 경우입니다. 커서는 1시간 뒤에 만료되기도 합니다.
# First page: your filters, no cursor.
curl -s "https://tahoe.workonward.com/api/partner/v1/jobs?status=published&limit=100" \
-H "Authorization: Bearer $TAHOE_API_KEY"
# Every later page: the SAME filters, plus the cursor from the last response.
curl -s "https://tahoe.workonward.com/api/partner/v1/jobs?status=published&limit=100&cursor=cur_..." \
-H "Authorization: Bearer $TAHOE_API_KEY"페이지를 넘길 수 있는 깊이
한 리스트는 최대 10,000행 깊이까지 페이지를 넘길 수 있습니다. 그보다 깊이 가면 400 result_window_exceeded가 반환됩니다. 사본을 최신으로 유지하려면 마지막으로 성공한 동기화 시점부터 ?updated_after=로 필터링하거나, 깊이 제한이 없는 변경 피드를 따라가세요. 변경 피드에는 별도의 위치 표시가 있습니다. 커서가 아니라 ?after=로 보내는 sequence 번호입니다.
타임스탬프
모든 날짜와 시간은 UTC 기준 RFC 3339 형식이며, 밀리초와 문자 Z가 붙습니다(2026-09-09T10:14:22.510Z). 현지 시간이나 epoch 숫자는 쓰지 않습니다. 날짜만 담는 필드는 YYYY-MM-DD 형식입니다.
updated_after 같은 시간 필터에도 같은 형식으로 보내세요. 시간대가 없는 값은 UTC로 읽습니다. 유효한 타임스탬프가 아닌 값은 400 invalid_timestamp를 반환합니다.
curl -s "https://tahoe.workonward.com/api/partner/v1/applications?updated_after=2026-09-01T00:00:00.000Z" \
-H "Authorization: Bearer $TAHOE_API_KEY"가려진 필드
두 번 읽어 둘 만한 규칙입니다. 잘못 처리하면 몇 달 뒤 고객을 통해서야 알게 되는 버그가 생깁니다. 응답에 필드가 없는 이유는 세 가지이며, 응답은 항상 그중 어느 것인지 알려 줍니다.
| 보이는 형태 | 의미 |
|---|---|
| 필드가 없음 | Tahoe가 이 값을 보유하고 있지 않습니다. |
필드가 있고 값이 null이거나 빈 리스트 | Tahoe가 보유하고 있으며, 값이 비어 있습니다. |
필드가 없고 restricted에 이름이 있음 | Tahoe가 보유하고 있지만 제공하지 않습니다. 이유는 restricted_reason에 나옵니다. |
가려진 필드는 아무 표시 없이 null로 오지 않습니다. 응답에서 제거되고 이름이 명시됩니다.
{
"object": "application_score",
"application_id": "app_6Qm2xKd4Rp8v",
"match_pct": 82,
"gaps": ["No forklift certification stated"],
"restricted": ["rationale", "model_version"],
"restricted_reason": {
"rationale": "scope_required:applications:internal:read",
"model_version": "scope_required:applications:internal:read"
}
}나타날 수 있는 이유는 다음과 같습니다.
| 이유 | 의미 | 대처 방법 |
|---|---|---|
scope_required:<scope> | 키에 해당 스코프가 없습니다. | 워크스페이스가 부여에 동의한다면, 해당 스코프가 있는 키를 Tahoe에 요청하세요. |
filtered:not_in_current_form | 일부 양식 답변이 제외되었습니다. 폐기된 필드, 동의 필드, 고용 평등 관련 질문에 대한 답변은 반환되지 않습니다. 이유 문구는 이 코드로 시작하고 짧은 설명이 덧붙습니다. | 할 일이 없습니다. 받은 답변은 남아 있는 필드에 대해서는 빠짐없이 완전합니다. |
never_exposed:consent_scope | 데이터는 존재하지만, 전화 스크리닝 내용처럼 당사자가 공유에 동의한 범위 밖에 있습니다. 어떤 스코프로도 열 수 없습니다. | 할 일이 없습니다. 이 값을 위한 필드를 만들지 마세요. |
Tahoe의 이력서 열람 기간 규칙에 따라 잠긴 이력서는 다르게 처리됩니다. 이력서 엔드포인트가 현재 상태와 잠금 해제 비용과 함께 403 resume_locked를 반환합니다. 절대 공개하지 않는 정보를 참고하세요.
정확한 매칭과 추정 매칭
Tahoe는 같은 사람에 속한 프로필들을 서로 연결합니다. LinkedIn URL로 만든 연결은 정확한 매칭(exact)이며, 하나의 프로필을 가리킵니다. 이메일 주소로 만든 연결은 추정 매칭(probable)입니다. 여러 사람이 함께 쓰는 주소, 다른 사람에게 다시 배정된 주소, 역할용 주소가 있기 때문입니다.
이 구분은 실제로 적용됩니다. 추정 매칭은 한 인물에 연결된 프로필 목록에는 나타날 수 있지만, 여러 프로필의 데이터를 합치는 엔드포인트에 값을 보태는 일은 없습니다. 그래서 한 사람의 전화번호가 다른 사람의 레코드에 들어가는 일은 생기지 않습니다. 인물을 조회할 때는 match.confidence를 읽고, probable은 사람이 확인해야 할 제안으로 다루세요. 인물을 참고하세요.
응답의 링크
객체에는 links 블록이 있습니다. 링크는 전체 URL이 아니라 항상 경로입니다. 링크를 따라가려면 앞에 https://tahoe.workonward.com을 붙이세요.
{
"object": "job",
"id": "job_7Kd2mXq4Rp8v",
"links": {
"self": "/api/partner/v1/jobs/job_7Kd2mXq4Rp8v",
"applications": "/api/partner/v1/jobs/job_7Kd2mXq4Rp8v/applications"
}
}curl -s "https://tahoe.workonward.com/api/partner/v1/jobs/job_7Kd2mXq4Rp8v/applications" \
-H "Authorization: Bearer $TAHOE_API_KEY"중복 요청 방지(Idempotency)
클라이언트는 타임아웃, 끊긴 연결, 재시작된 워커 뒤에 요청을 다시 보냅니다. 쓰기를 다시 보냈다고 후보자가 두 번 옮겨지거나, 이메일이 두 번 가거나, 메모가 두 번 달리면 안 됩니다. 쓰기에 Idempotency-Key 헤더를 보내면, 그 키로 온 첫 요청이 작업을 하고 같은 키와 같은 본문으로 온 이후 요청에는 작업을 다시 하지 않고 첫 응답을 돌려줍니다.
curl -X POST https://tahoe.workonward.com/api/partner/v1/applications/app_6Qm2xKd4Rp8v/move \
-H "Authorization: Bearer $TAHOE_API_KEY" \
-H "Idempotency-Key: move-6Qm2-to-screen-0001" \
-H "Content-Type: application/json" \
-d '{ "stage_id": "stg_3Rp8vKd2mXq4" }'HTTP/2 200
Idempotent-Replayed: true
Tahoe-Request-Id: req_3f9a1c7e5b2d4a60| 규칙 | 내용 |
|---|---|
| 형식 | A-Z a-z 0-9 . _ : ~ - 문자로 8~255자. 그 밖의 값은 400 invalid_idempotency_key입니다. 무작위 UUID도 좋고, 해당 작업에 대해 내 기록에 있는 ID(예: move-6Qm2-to-screen-0001)도 좋습니다. |
| 범위 | 기록은 요청을 보낸 키, 워크스페이스, 메서드, 경로, 그리고 내 Idempotency-Key 값에 묶입니다. 다른 API 키는 이 기록을 재생할 수 없고, 같은 값을 다른 경로로 보내면 다른 쓰기입니다. |
| 같은 키, 같은 본문 | 첫 응답이 Idempotent-Replayed: true와 함께 돌아옵니다. 쓰기는 다시 실행되지 않고 이벤트도 다시 발생하지 않습니다. JSON 키의 순서와 본문의 공백은 상관없습니다. |
| 같은 키, 다른 요청 | 409 idempotency_key_reused. 다른 것을 요청하는데 첫 응답을 돌려주면 두 번째 요청이 조용히 사라지므로 Tahoe는 거부합니다. 새 요청에는 새 키를 쓰세요. |
| 같은 키, 첫 요청이 아직 실행 중 | 409 idempotency_in_flight. 잠시 후 다시 시도하세요. 첫 요청이 중단된 경우 그 선점은 약 2분 뒤에 풀립니다. |
| 유지 기간 | 첫 요청부터 24시간. |
| 저장되는 것 | 2xx 응답만 저장합니다. 실패한 요청은 키를 놓아 주므로, 본문을 고친 뒤나 장애 뒤의 재시도는 다시 실행됩니다. |
| 선택인가 필수인가 | 대부분의 쓰기에서 선택입니다. 중복이 되돌릴 수 없는 피해를 주는 곳에서는 필수입니다. POST /messages는 키가 없으면 400 idempotency_key_required를 반환합니다. |
{
"detail": {
"code": "idempotency_key_reused",
"type": "conflict",
"message": "This Idempotency-Key was already used with a different request. Use a new key for a new request.",
"param": null
}
}키는 HTTP 요청 하나를 반복해도 안전하게 만듭니다. 호출 중에는 키 없이도 반복이 안전한 것이 있습니다. POST /applications는 후보자의 이메일과 source로 기존 지원서를 찾고, POST /jobs는 source로 공고를 찾습니다. 가능한 보호는 모두 쓰세요.
이벤트의 출처(origin)
API로 한 쓰기가 무언가를 바꾸면, 그 쓰기가 만든 이벤트의 data.object 안에 origin이 들어 있습니다. api: 뒤에 쓰기를 한 키의 GET /me id가 붙은 문자열입니다. Sign in with Tahoe 토큰이면 app: 뒤에 앱의 클라이언트 ID가 붙으며, 대신 일한 사람은 나타나지 않습니다. Tahoe 제품에서 한 변경에는 origin이 없습니다. 이 값으로 연동 프로그램은 자기가 쓴 변경의 에코를 두 번 반영하지 않고 건너뛸 수 있습니다.
{
"object": "event",
"id": "evt_2Wp5nRc8Kd3x",
"type": "application.stage_changed",
"sequence": 48219,
"data": {
"object": {
"object": "application",
"id": "app_6Qm2xKd4Rp8v",
"stage_id": "stg_3Rp8vKd2mXq4",
"origin": "api:pk_4f1a9c07d2e85b36"
},
"previous_attributes": { "stage_id": "stg_9Kd2mXq4Rp8v" }
}
}def handle(event, own_origin):
# An event caused by a write from this key carries its origin.
# A change made in Tahoe has no origin at all.
if event["data"]["object"].get("origin") == own_origin:
return # the echo of our own write
apply(event)- 키가 만든 모든 이벤트의
origin은 같습니다. 내 키의 정확한 값을 알아내는 가장 쉬운 방법은 내가 한 쓰기가 만든 첫 이벤트에서 읽어 보관하는 것입니다. - 키 ID는 식별자이며 비밀이 아닙니다. 어느 키가 썼는지만 알려 주고, 누가 왜 했는지는 알려 주지 않습니다.
- 같은 워크스페이스를 쓰는 다른 연동 프로그램은 내 이벤트에서 내
origin을 보게 되며, 그 이벤트는 자기가 한 변경이 아니므로 그대로 반영해야 합니다. - 이벤트는 그대로 전달됩니다.
origin은 알아보게 해 줄 뿐입니다. 데이터 사본을 보관한다면 에코에 새updated_at도 들어 있으므로 필요할 수 있습니다.
응답 헤더
| 헤더 | 포함되는 응답 | 의미 |
|---|---|---|
Tahoe-Api-Version | 모든 응답 | 응답한 API 버전(예: 2026-09-09). |
Tahoe-Request-Id | 모든 응답 | 이 요청의 고유 ID. 로그에 남기고, 문의할 때 알려 주세요. |
RateLimit-Limit | 모든 응답 | 호출한 엔드포인트의 분당 한도. |
RateLimit-Policy | 모든 응답 | 세 가지 요청 한도를 모두 담은 문자열. |
X-RateLimit-Limit | 모든 응답 | X- 헤더만 읽는 클라이언트를 위한 RateLimit-Limit 사본. |
Retry-After | 429 응답 | 재시도 전에 기다릴 초 수. 추측하지 말고 이 값을 쓰세요. |
Idempotent-Replayed | 저장된 응답으로 답한 쓰기 | 반복된 Idempotency-Key에 대한 첫 응답을 저장해 둔 것으로 돌려준 경우 true입니다. 쓰기를 다시 실행한 결과가 아니라는 뜻입니다. |
알 수 없는 파라미터
API가 인식하지 못하는 쿼리 파라미터는 거부되지 않고 무시됩니다. 덕분에 이후 버전에서 추가될 파라미터를 보내도 클라이언트가 계속 작동하지만, 필터 이름에 오타가 있으면 필터가 적용되지 않은 데이터가 아무 경고 없이 반환되기도 합니다. 새 필터를 처음 쓸 때는 결과를 이미 알고 있는 작은 데이터로 확인해 보세요.
두 곳은 예외적으로 엄격합니다. 변경 피드의 ?type=에 알 수 없는 이벤트 유형을 넣으면 400 unknown_event_type이, 어떤 쓰기든 본문에 알 수 없는 필드를 넣으면 400 invalid_request가 반환됩니다.