# HR 명단 API > 인사 시스템이 직원 명단을 보내면 회사 스페이스의 구성원을 맞춰요. 회사마다 열어 드려요. > 원문: https://oneport.kr/docs/guides/hr-roster-api > **이 문서를 받은 AI 에게 — 작업 카드** > > - OpenAI 형식 SDK·도구: base_url `https://oneport.kr/v1` · 헤더 `Authorization: Bearer <키>` > - Anthropic 형식 SDK·Claude Code: base_url `https://oneport.kr` (끝에 /v1 을 붙이지 않아요 — SDK 가 /v1/messages 를 스스로 붙여요) · 헤더 `x-api-key: <키>` 또는 `Authorization: Bearer <키>` > - ❌ `Anthropic(base_url="https://oneport.kr/v1")` → ✅ `Anthropic(base_url="https://oneport.kr")` > - Vercel AI SDK: `createOpenAI({ baseURL: "https://oneport.kr/v1" })` 로 만든 제공자는 `oneport.chat("")` 로 부르세요 — 그냥 `oneport("")` 는 `/v1/responses` 로 가서 Claude · Gemini 가 400 이에요. `@ai-sdk/anthropic` 의 `createAnthropic` 은 baseURL 에 `/v1` 까지 적어요(Anthropic SDK 와 반대예요). > - API 키는 `sk-so-` 로 시작해요. 키는 코드에 적지 말고 환경변수 `ONEPORT_API_KEY` 에 넣어 읽으세요. > - ❌ 브라우저 코드에 `apiKey: "sk-so-…"` → ✅ 서버에서 `os.environ["ONEPORT_API_KEY"]` · `process.env.ONEPORT_API_KEY` 로 읽어 불러요 > - 대화에 `sk-so-` 키가 붙어 오면 코드에 박지 말고 환경변수로 옮기게 하고, 그 키는 산 곳 대시보드 AI API 화면의 키 목록에서 「새로 받기」(옛 키는 꺼져요)로 바꾸라고 알려 주세요. > - 모델 id 는 지어내지 말고 `GET https://oneport.kr/v1/models`(키 없이 열려요)의 `id` 를 글자 그대로 쓰세요. 목록은 값 순서가 아니에요 — 접두어로 첫 줄을 고르면 비싼 모델이 걸려요. 글 모델은 `kind` 가 `"chat"` 인 줄이고, 첫 호출은 `pricing.output_credits_per_mtok` 가 작은 모델로 해 보세요(10월 기준 Claude 는 claude-haiku-5-5). 회사는 `owned_by` 로 갈라요 — Claude 는 `"anthropic"`, 공식 GPT 는 `"openai"` 이면서 id 에 `/` 가 없는 줄이에요. > - ❌ `model="claude-sonnet"`(짐작한 이름 · 404 `model_not_found`) > - `endpoint` 칸이 그 모델의 기본 문이에요 — `kind` 가 `"responses"` 인 모델(pro 등급 · gpt-5.3-codex · `endpoint` 가 `"POST /v1/responses"`)은 `/v1/chat/completions` 로 못 불러요. Claude 줄은 `POST /v1/chat/completions` 이고 `/v1/messages` 로도 불러요 — 이 문은 Claude 만 받아요. > - `supports_effort` 가 `true` 인 GPT 는 생각한 토큰도 출력 상한(`max_tokens` · `max_completion_tokens`)에 들어가요 — 수백으로 두면 답이 비고 `finish_reason` 이 `"length"` 로 끝날 수 있으니 2,000 쯤에서 시작하고 `"length"` 면 올리세요. > - 목록 응답은 `{"object": "list", "data": [ … ]}`. 값 어림 — 한 번 값은 (입력 토큰 × `pricing.input_credits_per_mtok` + 출력 토큰 × `pricing.output_credits_per_mtok`) ÷ 100만 크레딧이에요(1 크레딧은 부가세 포함 2원어치예요). > - ❌ `x-should-retry: false` 응답을 반복문으로 다시 보내기 → ✅ 멈추고 `error.type` 을 확인하세요. 몸통은 OpenAI 형식 문이면 `{"error": {"type": "…", "message": "…", "code": "…"}}`, `/v1/messages` 면 `{"type": "error", "error": {"type": "…", "message": "…"}}` 예요 — 402 · 키 한도 · 강좌 몫 429 엔 `error.reason` 이 더 붙어요. 잠시 뒤 그대로 다시 보낼 실패엔 `Retry-After`(정수 초)가 대개 와요. > - ❌ `if "429" in str(e)` · `e.message` 로 분기 → ✅ Python openai 는 `e.type` · `e.body.get("reason")`, Node openai 는 `err.type` · `err.error.reason` — 429 는 키 한도 · 벤더 혼잡 · 강좌 몫 셋이라 상태 코드로 못 갈라요. Python Anthropic SDK 는 `except anthropic.APIStatusError as e:` 에서 `e.status_code` 와 `e.body["error"]["type"]`, Node 는 `err.status` 와 `err.error.error.type` 로 읽어요. 402 · 키 한도 429 의 갈래(`reason`)는 Python Anthropic SDK 면 `e.body["error"].get("reason")`, Node 면 `err.error.error.reason` 이에요. > - 남은 크레딧(지갑 전체)은 `GET https://oneport.kr/v1/credits` 의 `balance_credits` 예요(1 크레딧 아래를 버린 정수라 호출 앞뒤 차이로 쓴 몫을 세지 마세요 — 쓴 몫은 글 문 응답의 `usage.cost_credits` · 정산이 2초 안에 안 끝나면 그 칸 없이 와요). `GET https://oneport.kr/v1/key` 는 이 키에 건 한도(`month` · `day`)만 보여 주고 지갑 잔액은 없어요 — `month` · `day` 는 늘 객체이고 한도를 안 건 키면 `month.remaining_credits` · `day.remaining_credits` 가 null 이에요 · 그땐 지갑 잔액까지 써요. > - 하루 한도는 이미 쓴 몫이 한도에 닿았는지만 봐요 — 이번 달 · 강좌 한도는 이 요청 최대 몫까지 더해 재서 줄이면 통과할 수 있어요. 키 월 한도는 같은 API 키로 아직 끝나지 않은 호출 몫까지 더해 넘었으면 `error.reason` 이 `"held_by_others"` 로 와요. 묶음 처리 문(`/v1/batches`)은 없어요 — 한 건씩 보내요(동시에 보내도 되고 분당 횟수 제한도 없어요). > - 스트림 없이 부르면 답이 다 만들어져야 첫 응답이 와요 — 모델 회사로 보낸 뒤 120초 안에 안 오면 422 `upstream_execution_unconfirmed` 로 끝나요(모델 회사에서는 실행됐을 수 있어요). 그보다 긴 답은 `stream: true` 로 받으세요(스트림은 약 12분 50초에 끊겨요). SDK 시간 제한은 300초로 두세요(Python `OpenAI(…, timeout=300)` · Node `new OpenAI({ …, timeout: 300_000 })` — Node 는 밀리초 · SDK 동작). > - 첫 응답 120초 시계는 스트림이어도 같아요. 이 422 는 `x-should-retry: false` 로 와요 — 같은 요청을 자동으로 다시 보내지 마세요. stream: true 로 바꾸는 것도 새 생성이라 자동으로 하지 않아요. `Idempotency-Key` 를 실어 보낸 요청이면 받은 `x-gateway-status-url` 을 같은 API 키로 GET 조회하세요. `Idempotency-Key` 없이 보낸 요청엔 조회 주소가 안 오고 요청 ID 로 만든 주소로는 조회가 안 돼요 — 요청 ID 와 부른 시각을 admin@schoolorder.kr 로 알려 주세요. 요청 ID 는 머리글 `request-id`(같은 값이 `x-request-id`)와 오류 몸통 `error.request_id` 로 와요. 조회 주소는 `/v1/requests/` 꼴 상대 주소라 앞에 https://oneport.kr 를 붙여요. > - 고친 요청엔 새 Idempotency-Key 를 써요(같은 Idempotency-Key 로 본문을 바꾸면 409 `idempotency_conflict`). `Idempotency-Key` 를 실었으면 200 머리말을 받기 전의 연결 오류 · 시간 초과 뒤에 같은 Idempotency-Key · 같은 본문으로 다시 보내세요(몇 번 보내도 두 번 실행되지 않지만, 간격은 늘려 가며). 200 을 받은 뒤 끊긴 스트림은 잘린 답이라 같은 Idempotency-Key 는 409 예요 — 그때까지 쓴 토큰은 사용했고, 받은 글로 이어 받을 땐 새 Idempotency-Key 예요. > - 경계 사례는 이 장 전체 판(`https://oneport.kr/docs/guides/hr-roster-api.full.md`)과 「에러」 장(`https://oneport.kr/docs/errors.md`)에 있어요. > **회사마다 열어 드려요** > > 회사·기관 스페이스에서 쓰는 API 예요. 주인 · 관리자가 스페이스 설정 탭 「명단 API」에서 [열어 달라고 하기]를 누르면 담당자가 확인하고 열어요. 열리기 전엔 모든 요청이 404 not_found 로 답해요. SCIM 은 지원하지 않고, 이 JSON 명단 API 로 받아요. 회사 HR 시스템이 정해진 때마다 직원 명단을 보내면 스페이스의 구성원을 맞춰요. 정지와 복귀는 늘 스페이스 관리자가 확인해요. API 로 넣은 직원에게는 초대 메일이 가지 않아요. ## 준비 - 스페이스가 열리면 주인이 설정 탭 「명단 API」에서 토큰을 만들어요. - 범위는 읽기와 쓰기예요. 읽기는 상태 보기와 dry_run, 쓰기는 apply 까지예요. 쓰기는 허용 IP 를 하나 이상 적어야 해요(최대 10개 · 예: 203.0.113.10/32). - 토큰은 만든 창에서 한 번만 보여요. HR 시스템의 비밀 저장소에 넣어 주세요. - 스페이스마다 살아 있는 토큰은 2개까지예요(바꿀 때 겹쳐 쓰는 몫). - 서버의 배치 작업에서 불러요. 브라우저에서는 부를 수 없어요. ## 주소 | 메서드 | 주소 | 범위 | 하는 일 | | --- | --- | --- | --- | | POST | https://oneport.kr/api/spaces/roster/v1/runs | dry_run 은 읽기 · apply 는 쓰기 | 명단 보내기 | | GET | https://oneport.kr/api/spaces/roster/v1/runs/{id} | 읽기 | 회차 하나 보기 | | GET | https://oneport.kr/api/spaces/roster/v1/status | 읽기 | 스페이스 명단 상태 보기 | - Authorization: Bearer <토큰> — 늘 필요해요. - Content-Type: application/json — 명단을 보낼 때. - Idempotency-Key: <8–200자> — apply 에 꼭 필요해요. 파일 내용의 해시를 권해요. 같은 키 · 같은 본문은 한 번만 맞추고 처음 응답을 다시 줘요(replayed: true). ## 본문 ```json { "mode": "dry_run", "generated_at": "2026-11-04T09:00:00+09:00", "fields": [ "email", "employee_id", "team", "team_admin" ], "rows": [ { "email": "kim@corp.example", "employee_id": "E1001", "team": "영업팀", "team_admin": false } ] } ``` **요청 본문** | 파라미터 | 받는 값 | 저희가 하는 일 | | --- | --- | --- | | `mode` (필수) | dry_run · apply | dry_run 은 결과만 보여 주고 아무것도 안 바꿔요. apply 는 맞춰요. | | `generated_at` (필수) | 시간대가 붙은 시각(+09:00 또는 Z) | HR 시스템이 명단을 만든 시각이에요. 이미 맞춘 명단보다 오래됐으면 409 older_than_applied 로 답해요. | | `fields` (필수) | email · employee_id · team · team_admin | 이 명단이 쓰는 칸이에요. email 은 꼭 있어야 해요. | | `rows` (필수) | 줄 목록 | 지금 재직 중인 전체 명단이에요. 명단에 없는 분은 정지 후보가 되어 관리자 확인을 기다려요. | - 한 번에 받는 줄 수는 스페이스 자리 수에 따라 정해져요(넘으면 413 too_many_rows · detail.max). 본문은 4MB 까지예요. - employee_id 를 보내면 메일이 바뀐 직원도 같은 분으로 이어 맞춰요. 엑셀이 앞자리 0 을 지우지 않게 글자로 보내 주세요. - 처음에는 dry_run 으로 보내 run.plan 을 확인한 뒤 같은 명단을 apply 로 보내 주세요. - 보낸 명단은 맞추는 동안과 관리자 확인을 기다리는 동안 7일까지 보관한 뒤 지워요. dry_run 명단은 보관하지 않아요. ## 응답 ```json { "ok": true, "replayed": false, "run": { "id": "…", "state": "review", "mode": "apply", "rows": 1, "generated_at": "…", "plan": { "new": 1, "same": 0, "suspend": 0 }, "applied": { "added": 0 }, "waiting": { "new": 1 }, "hold_reasons": ["api_mode_review"], "waiting_reasons": [], "blocked": null, "errors": [], "review_url": "https://oneport.kr/…", "followups_left": 0, "created_at": "…", "finished_at": "…" }, "warnings": [] } ``` 응답에는 이메일 · 사번이 실리지 않아요(수만 실려요). | run.state | 뜻 | | --- | --- | | judging | 판정 중이에요 | | dry_run | 결과만 보여 드렸어요(바뀐 것 없음) | | applying | 맞추는 중이에요(큰 명단은 나눠 이어 가요 · followups_left) | | applied | 맞췄어요 | | review | 관리자 확인을 기다려요(review_url) | | reviewed | 관리자가 확인해 맞췄어요 | | dismissed | 관리자가 이 명단을 버렸어요 | | superseded | 더 새 명단이 와서 이 회차는 접혔어요 | | expired | 확인 없이 7일이 지나 접혔어요 | | blocked | 이 명단으로는 맞추지 않았어요(blocked) | | failed | 끝내지 못했어요(errors) | ## 관리자 확인으로 넘어가는 까닭 | hold_reasons | 뜻 | | --- | --- | | api_mode_review | 스페이스가 「확인 뒤 맞추기」예요(처음 몇 번은 이렇게 둬요) | | stale_roster | 복귀와 정지가 함께 많아요(옛 명단 같아요) | | domain_mismatch | 명단의 메일 도메인이 지금 구성원과 달라요 | | shrink | 지난번 명단보다 줄이 10% 넘게 줄었어요 | | old_file | 만든 지 72시간이 넘은 명단이에요 | | long_gap | 오래 끊겼다가 다시 온 명단이에요 | | id_mismatch | 사번이 맞지 않는 줄이 많아요 | | problems_many | 손으로 정리할 줄이 많아요 | ## 자동으로 안 하고 기다리는 줄의 까닭 | waiting_reasons | 뜻 | | --- | --- | | adds_over_limit | 새로 넣을 분이 한 번에 자동으로 넣는 수를 넘었어요 | | seat_short | 자리가 모자라요(관리자가 [자리 더 열어 달라고 하기]로 요청해요) | | adds_foreign_domain | 스페이스가 모르는 도메인의 메일이에요 | | new_team | 아직 없는 팀이에요 | | daily_cap | 24시간 자동 맞추기 수가 끝에 닿았어요 | | moves_over_limit | 팀 이동이 한 번에 자동으로 옮기는 수를 넘었어요 | | privilege_up | 팀 담당으로 올리는 줄이에요 | | last_team_admin | 팀의 마지막 담당을 내리는 줄이에요 | | restore_needs_review | 복귀는 늘 사람이 확인해요 | | suspend_needs_review | 정지는 늘 사람이 확인해요 | | rekey_joined | 이미 들어온 분의 메일이 바뀌었어요 | | rekey_admin | 관리자의 메일이 바뀌었어요 | | rekey_domain | 모르는 도메인으로 메일이 바뀌었어요 | | rekey_over_limit | 메일 바뀜이 한 번에 자동으로 잇는 수를 넘었어요 | ## 오류 ```json { "ok": false, "error": { "code": "bad_row", "message": "…", "detail": { "row_nos": [3], "count": 1, "row_problems": [{ "row": 3, "field": "email", "reason": "invalid" }] } } } ``` - detail.row_problems — 줄 번호(1부터) · 칸(row · email · employee_id · team · team_admin) · 까닭(not_object · missing · invalid · not_boolean). 값은 싣지 않아요. - Retry-After 머리글이 오면 그만큼 기다린 뒤 같은 Idempotency-Key 로 다시 보내 주세요. - 토큰을 알아본 응답에는 Oneport-Token-Expires-In(남은 날 수)이 붙고, 14일 안으로 남으면 Warning 머리글도 붙어요. | HTTP | 코드 | 뜻 · 할 일 | | --- | --- | --- | | 400 | bad_body | 본문 꼴이 틀렸어요(detail.field · detail.reason) | | 400 | bad_row | 꼴이 틀린 줄이 있어요(detail.row_problems) | | 400 | bad_idempotency_key | apply 에 Idempotency-Key 가 없거나 꼴이 틀렸어요 | | 401 | token_missing | 토큰이 없어요 | | 401 | token_invalid | 토큰을 알아보지 못했어요 | | 401 | token_expired | 토큰 기한이 지났어요(주인이 새로 만들어요) | | 401 | token_revoked | 끊긴 토큰이에요 | | 401 | token_issuer_changed | 토큰을 만든 분이 더 이상 주인이 아니에요(지금 주인이 새로 만들어요) | | 403 | origin_not_allowed | 브라우저에서 불렀어요 | | 403 | ip_not_allowed | 허용 IP 밖이에요 | | 403 | scope_missing | 읽기 토큰으로 apply 를 보냈어요 | | 403 | space_closed | 닫힌 스페이스예요 | | 403 | not_org | 회사·기관 스페이스가 아니에요 | | 404 | run_not_found | 그 회차가 없어요 | | 404 | not_found | 찾지 못했어요(아직 열리지 않은 스페이스도 이렇게 답해요) | | 409 | run_active | 이 스페이스에서 맞추는 중이에요(Retry-After) | | 409 | idempotency_in_progress | 같은 키의 요청을 아직 판정하고 있어요(Retry-After) | | 409 | idempotency_mismatch | 같은 키로 다른 명단이 왔어요(새 명단이면 새 키) | | 409 | older_than_applied | 이미 맞춘 명단보다 오래된 명단이에요 | | 413 | body_too_large | 본문이 4MB 를 넘어요 | | 413 | too_many_rows | 줄 수가 상한을 넘었어요(detail.max) | | 415 | unsupported_media_type | Content-Type 이 JSON 이 아니에요 | | 422 | duplicate_email | 같은 메일이 두 줄 넘게 있어요(detail.row_nos) | | 422 | duplicate_employee_id | 같은 사번이 두 줄 넘게 있어요(detail.row_nos) | | 422 | field_not_declared | fields 에 없는 칸이 든 줄이 있어요 | | 422 | roster_blocked | 이 명단으로는 맞추지 않았어요(detail.block) | | 429 | rate_limited | 요청이 잦아요(Retry-After) | | 503 | not_ready | 준비 중이에요(Retry-After) | | 503 | unread | 스페이스 정보를 끝까지 읽지 못했어요(Retry-After) | ## 예시 ```bash # 1) 결과만 보기 curl -X POST https://oneport.kr/api/spaces/roster/v1/runs \ -H "Authorization: Bearer $ONEPORT_ROSTER_TOKEN" -H "Content-Type: application/json" \ --data-binary @roster-dry-run.json # 2) 맞추기 — 키는 파일 해시 curl -X POST https://oneport.kr/api/spaces/roster/v1/runs \ -H "Authorization: Bearer $ONEPORT_ROSTER_TOKEN" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(shasum -a 256 roster.json | cut -d' ' -f1)" \ --data-binary @roster.json # 3) 상태 curl https://oneport.kr/api/spaces/roster/v1/status -H "Authorization: Bearer $ONEPORT_ROSTER_TOKEN" ``` ## 자주 묻는 것 - SCIM 을 쓰나요? 아니요. HR 시스템이 위 JSON 명단을 보내 주세요. - 정지가 저절로 되나요? 아니요. 명단에서 빠진 분은 정지 후보로 관리자 확인을 기다려요. - 같은 명단을 두 번 보내면요? 같은 키면 처음 응답을 다시 드리고, 새 키여도 바뀐 것이 없으면 차이 0 으로 끝나요. - 명단이 끊기면요? 스페이스에 정한 주기(매일 · 매주 · 매달)를 넘기면 관리자 화면에 알림이 서요.