본인인증 API (성인 확인)
본인확인 기관 연동을 CP API 하나로 감싼 것입니다. 한국 사용자는 휴대폰 본인확인(NHN KCP), 해외 사용자는 얼굴 나이추정·신분증 확인(Didit)으로 갑니다 — 앱은 country 만 넘기면 됩니다. 앱 서버가 API Key 로 두 번 호출하면, 사용자가 성인인지와 앱 안에서 같은 사람인지 판별할 값만 돌려받습니다.
- 이름·전화번호·성별·CI/DI 원문·생년월일 전체·신분증 번호·얼굴 사진은 앱에 전달되지 않습니다. CP 도 저장하지 않습니다.
- 브라우저에서 직접 부르지 마세요. API Key 는 서버에만 둡니다.
권한
- 앱의 서비스 목록에
identity가 켜져 있어야 합니다. - API Key 에
identity.verify권한이 있어야 합니다(identity.*도 가능).pay.*로는 호출할 수 없습니다. - 서비스를 새로 켰다면 새 키를 발급하세요. 이미 발급된 키의 권한은 바뀌지 않습니다.
테스트 모드와 라이브 모드
모드는 live 키이면서 서비스가 본인확인 live 로 승인된 경우에만 live 이고, 나머지는 모두 test 입니다. 본인확인 live 승인은 결제 live 승인과 별개로 받을 수 있습니다(결제가 test 인 서비스도 본인확인만 live 가능). 결제 live 로 승인된 서비스는 본인확인도 live 입니다.
| 모드 | 동작 |
|---|---|
| test | 결정적 샌드박스. 실제 인증 창이 뜨지 않고, sandbox.birthday 로 성인/미성년을 고릅니다. |
| live | 실제 본인확인. 응답의 provider 가 어느 창을 여는지 알려 줍니다(portone·toss_cert·didit). 샌드박스 파라미터를 보내면 400 입니다. |
해외(country 가 KR 이 아님) 요청은 test 모드에서 Didit 샌드박스로 갑니다. 이때는 sandbox 파라미터를 보내지 마세요 — 보내면 국가와 상관없이 위의 결정적 샌드박스로 갑니다. 결과(인증됨·검토 필요·거절됨)는 Didit 화면의 "테스트 결과 선택"에서 고르고, 셀카 단계는 실제 카메라가 필요합니다.
사용 가능 여부 확인
게이트를 켤지 정하려고 세션을 만들어 볼 필요는 없습니다. 세션을 만들지 않고 지금 이 키로 본인확인을 끝까지 할 수 있는지만 알려 줍니다.
GET /identity/availability?country=AU
X-API-Key: <identity.verify 권한 키>country(ISO-3166 두 글자, 선택)를 주면 그 나라 사용자 기준으로 답합니다. 생략하면 한국 기준입니다.
{ "mode": "live", "provider": "portone", "sandbox": false, "available": false }available: false 이면 세션 생성이나 확정이 503 IDENTITY_NOT_CONFIGURED 로 끝납니다. 이 동안에는 기존 연령 확인 흐름을 유지하고, true 가 되면 본인확인 게이트로 전환하세요. 운영 환경에서는 sandbox: true 를 "사용 가능"으로 보지 마세요.
1. 인증 세션 만들기
https://pay.cp-platform.com/identity/verifications{
"userRef": "user_123",
"successUrl": "https://your-app.example/identity/done",
"failUrl": "https://your-app.example/identity/failed",
"sandbox": { "birthday": "20080101", "personKey": "tester-a", "outcome": "success" }
}| 필드 | 설명 |
|---|---|
userRef | 앱의 사용자 식별자(필수, 128자 이하). 감사 기록에 남습니다. |
successUrl / failUrl | 인증 뒤 돌아갈 주소. https 또는 앱 스킴(yourapp://...). |
requestUrl | 토스 requestUrl. 생략하면 successUrl 을 씁니다(live 에서는 둘 중 하나 필수). |
sandbox | test 모드 전용. birthday(YYYYMMDD, 기본 19900101), outcome(success/fail), personKey(같으면 같은 ciHash, 기본 userRef). |
{
"verificationId": "7f0c…",
"mode": "live",
"provider": "toss_cert",
"sandbox": false,
"txId": "c1ce9214-…",
"authUrl": "https://auth.cert.toss.im/start?serviceType=SIGN_USER_AUTH",
"sdk": { "scriptUrl": "https://cdn.toss.im/cert/v1", "call": "tossCert.start({ authUrl, txId, onSuccess, onFail })" },
"expiresAt": "2026-09-24T04:00:00+00:00"
}클라이언트는 authUrl·txId 로 토스 표준창을 엽니다.
<script src="https://cdn.toss.im/cert/v1"></script>
<script>
tossCert.start({
authUrl, txId,
onSuccess: () => fetch("/api/identity/confirm", { method: "POST", body: JSON.stringify({ verificationId, txId }) }),
onFail: (e) => console.warn(e),
});
</script>test 모드에서는 authUrl 이 successUrl?txId=…&verificationId=… 입니다. 그 주소로 보내면 "인증을 마치고 돌아온" 상태가 됩니다.
provider: "portone" 인 경우
authUrl 은 null 이고, sdk.params 를 포트원 브라우저 SDK 에 그대로 넘깁니다. storeId·channelKey 는 공개값이라 브라우저에 두어도 됩니다(비밀키는 CP 서버에만 있습니다). identityVerificationId 는 CP 가 만든 값이며 txId 와 같습니다 — 클라이언트에서 새로 만들지 마세요.
{
"verificationId": "7f0c…",
"provider": "portone",
"txId": "cpiv3f9a…",
"authUrl": null,
"sdk": {
"scriptUrl": "https://cdn.portone.io/v2/browser-sdk.js",
"call": "PortOne.requestIdentityVerification(params)",
"params": { "storeId": "store-…", "identityVerificationId": "cpiv3f9a…", "channelKey": "channel-key-…", "redirectUrl": "https://your-app.example/identity/done" }
}
}<script src="https://cdn.portone.io/v2/browser-sdk.js"></script>
<script type="module">
const res = await PortOne.requestIdentityVerification(sdk.params);
if (res.code !== undefined) throw new Error(res.message); // 취소·실패
// 결과 판정은 서버에서만: 앱 서버가 CP 확정 API 를 부릅니다(SDK 응답은 신뢰하지 않음)
await fetch("/api/identity/confirm", { method: "POST", body: JSON.stringify({ verificationId, txId }) });
</script>모바일 웹은 인증 뒤 redirectUrl(= successUrl)로 돌아옵니다. 그때도 같은 방식으로 확정하세요. PC 에서는 KCP 창이 팝업으로 열리므로 사용자가 버튼을 눌렀을 때 requestIdentityVerification 을 부르세요(자동 호출하면 팝업 차단으로 바로 실패합니다).
해외 사용자 (country ≠ KR) — provider: "didit"
현재 해외 인증은 열려 있지 않습니다.
country가 KR 이 아니면400 COUNTRY_NOT_SUPPORTED를 돌려주고, 사용 가능 여부 확인(?country=)도available: false입니다. 아래는 다시 열릴 때의 사용법입니다.
세션 생성 요청에 country 를 넣으면 됩니다. 없거나 KR 이면 위의 한국 경로 그대로입니다.
{ "userRef": "user_123", "country": "AU", "successUrl": "https://your-app.example/identity/done" }| 필드 | 설명 |
|---|---|
country | 사용자가 사는 나라(ISO-3166 두 글자). 형식이 틀리면 422 |
method | 해외 전용, "DOCUMENT" 만 허용. 나이추정 미달 뒤 신분증 세션을 새로 만들 때 씁니다(KR 에 주면 400 METHOD_NOT_SUPPORTED) |
나라마다 방식이 정해져 있습니다.
| 나라 | 방식(method) | 사용자가 하는 일 |
|---|---|---|
| AU | AGE_ESTIMATION | 셀카로 얼굴 나이추정. 추정 15~25세면 같은 화면에서 신분증 확인으로 자동 전환 |
| 그 밖 | DOCUMENT | 신분증 촬영 + 셀카(얼굴 대조) |
응답의 authUrl 이 Didit 인증 화면이고 sdk 는 null 입니다. 그 주소로 이동(또는 웹뷰)시키면 됩니다. 끝나면 successUrl 로 돌아오고, 확정은 한국과 똑같이 서버에서 합니다.
{ "verificationId": "3895…", "mode": "live", "provider": "didit", "method": "AGE_ESTIMATION", "country": "AU",
"txId": "cpdi…", "authUrl": "https://verify.didit.me/session/…", "sdk": null, "expiresAt": "…" }- PC 브라우저에서 열면 QR 코드가 나오고, 사용자가 휴대폰으로 찍어 이어서 진행합니다(카메라 촬영이 필요해서입니다). 화면에 그렇게 안내해 주세요.
- 사는 나라는 사용자가 두 글자 코드를 입력하게 하지 말고 나라 이름 선택기로 받으세요. 접속 위치·가입 국가로 기본값을 채우면 좋습니다.
- ★한국 사용자가 해외를 골라 휴대폰 본인확인을 피하지 못하게, 나라는 앱 서버가 확인한 값(접속 위치·가입 국가 등)으로 정하세요. 확정 응답의
country는 앱이 보낸 값을 그대로 돌려줄 뿐 검증된 거주국이 아닙니다.
2. 결과 확정
https://pay.cp-platform.com/identity/verifications/{verificationId}/confirm{ "txId": "c1ce9214-…", "userRef": "user_123" }userRef 는 선택이지만 권장합니다. 보내면 세션을 만든 사용자와 다를 때 403 USER_REF_MISMATCH 로 막습니다(다른 사용자의 세션을 가져와 확정하는 것 방지).
{
"verificationId": "7f0c…",
"status": "verified",
"verified": true,
"isAdult": true,
"birthYear": 2001,
"ciHash": "9b1e…(64자 hex)",
"verifiedAt": "2026-09-24T03:05:12+00:00",
"mode": "live",
"sandbox": false,
"userRef": "user_123"
}- 멱등입니다. 이미 확정된 건은 같은 결과를 다시 돌려줍니다.
GET /identity/verifications/{verificationId}로 저장된 결과를 다시 읽을 수 있습니다.- 실패하면
verified: false,status: "failed",failureCode가 옵니다.
확정 응답에는 아래 필드도 함께 옵니다(기존 필드의 이름·의미는 그대로).
| 필드 | 설명 |
|---|---|
method | KCP · AGE_ESTIMATION · DOCUMENT — 실제로 판정에 쓴 방식 |
country | 세션을 만들 때 보낸 나라(요청값 그대로) |
personKey / personKeyKind | 앱 안 같은 사람 판별 값(= ciHash). 종류는 KR_CI(휴대폰 CI) · FACE(얼굴) · DOC(신분증) |
estimatedAgeLower | 나이추정일 때만, 추정 나이(정수 내림) |
nextStep | 나이추정이 기준에 못 미치면 "DOCUMENT" — 같은 country 에 method: "DOCUMENT" 로 새 세션을 만드세요 |
나이추정 결과에는 생년이 없습니다(birthYear: null). 얼굴 나이추정은 오차가 있어 추정 25세 이상일 때만 isAdult: true 입니다. 25세 미만이면 status: "failed"·failureCode: "AGE_ESTIMATE_BELOW_THRESHOLD"·nextStep: "DOCUMENT" 가 옵니다. 신분증 결과는 생년월일로 판정하므로 birthYear 가 있습니다.
isAdult 판정
생일이 지나 만 19년을 채운 날부터 true 입니다(한국 시간 기준, 2월 29일생은 평년 3월 1일).
청소년보호법은 "만 19세가 되는 해의 1월 1일을 맞이한 사람"을 청소년에서 빼므로 법적 기준은 이보다 조금 넓습니다. 이 API 는 더 늦게 성인으로 보는 쪽을 택했습니다. 연 나이 기준이 필요하면 birthYear <= 올해 - 19 로 직접 판정하세요.
ciHash
해외 인증에서는 같은 자리에 얼굴(FACE)·신분증(DOC) 기준 값이 들어갑니다. 서로 다른 종류끼리는 같은 사람이어도 값이 다릅니다.
CI 를 앱마다 다른 키로 HMAC 한 값입니다. 같은 앱에서는 같은 사람이면 항상 같은 값이라 다계정·중복 가입을 막는 데 쓸 수 있습니다. 앱이 다르면 값도 달라서 앱끼리 사람을 연결할 수 없고, 원래 CI 로 되돌릴 수도 없습니다.
오류
본문은 {"detail": {"code", "message"}} 형태입니다.
| HTTP | code | 의미 |
|---|---|---|
| 400 | TX_ID_MISMATCH | txId 가 이 세션 것이 아님 |
| 400 | SANDBOX_NOT_ALLOWED_IN_LIVE | live 모드에 sandbox 파라미터 |
| 400 | INVALID_SANDBOX_BIRTHDAY / REQUEST_URL_REQUIRED | 입력 오류 |
| 400 | INVALID_METHOD / METHOD_NOT_SUPPORTED | method 가 DOCUMENT 가 아니거나, 한국 요청에 method 를 줌 |
| 400 | COUNTRY_NOT_SUPPORTED | 해외(country ≠ KR) 인증이 지금 열려 있지 않음 |
| 403 | USER_REF_MISMATCH | 확정 요청의 userRef 가 세션 소유자와 다름 |
| 403 | — | identity.verify 권한 없음 또는 앱에 identity 서비스 꺼짐 |
| 404 | VERIFICATION_NOT_FOUND | 없거나, 다른 앱의 세션이거나, 다른 모드(test↔live) 키로 조회 — 모두 같은 응답 |
| 409 | VERIFICATION_NOT_COMPLETED | 사용자가 아직 인증을 끝내지 않음. 잠시 뒤 다시 확정 |
| 410 | VERIFICATION_EXPIRED | 생성 후 60분 경과. 새 세션을 만드세요 |
| 502 | IDENTITY_PROVIDER_ERROR | 인증기관 일시 장애. 세션은 유지되니 재시도 |
| 503 | IDENTITY_NOT_CONFIGURED | 해당 모드의 본인확인이 아직 열리지 않음 |
보관
CP 는 앱 ID·userRef·결과·출생연도·ciHash(personKey)·방식·나라·추정 나이(정수)·시각만 남깁니다. 해외 인증의 생년월일 전체·신분증 번호·얼굴 사진은 남기지 않습니다. 미완료 기록은 30일, 확인된 기록은 1년 뒤 삭제합니다.