본인인증 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. 인증 세션 만들기

POSThttps://pay.cp-platform.com/identity/verifications
인증
API Key
권한
identity.verify
Rate Limit
120 / min
request
{
  "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 에서는 둘 중 하나 필수).
sandboxtest 모드 전용. birthday(YYYYMMDD, 기본 19900101), outcome(success/fail), personKey(같으면 같은 ciHash, 기본 userRef).
201 response
{
  "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 와 같습니다 — 클라이언트에서 새로 만들지 마세요.

201 response (portone)
{
  "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 이면 위의 한국 경로 그대로입니다.

request
{ "userRef": "user_123", "country": "AU", "successUrl": "https://your-app.example/identity/done" }
필드설명
country사용자가 사는 나라(ISO-3166 두 글자). 형식이 틀리면 422
method해외 전용, "DOCUMENT" 만 허용. 나이추정 미달 뒤 신분증 세션을 새로 만들 때 씁니다(KR 에 주면 400 METHOD_NOT_SUPPORTED)

나라마다 방식이 정해져 있습니다.

나라방식(method)사용자가 하는 일
AUAGE_ESTIMATION셀카로 얼굴 나이추정. 추정 15~25세면 같은 화면에서 신분증 확인으로 자동 전환
그 밖DOCUMENT신분증 촬영 + 셀카(얼굴 대조)

응답의 authUrl 이 Didit 인증 화면이고 sdk 는 null 입니다. 그 주소로 이동(또는 웹뷰)시키면 됩니다. 끝나면 successUrl 로 돌아오고, 확정은 한국과 똑같이 서버에서 합니다.

201 response (didit)
{ "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. 결과 확정

POSThttps://pay.cp-platform.com/identity/verifications/{verificationId}/confirm
인증
API Key
권한
identity.verify
Rate Limit
120 / min
request
{ "txId": "c1ce9214-…", "userRef": "user_123" }

userRef 는 선택이지만 권장합니다. 보내면 세션을 만든 사용자와 다를 때 403 USER_REF_MISMATCH 로 막습니다(다른 사용자의 세션을 가져와 확정하는 것 방지).

200 response
{
  "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 가 옵니다.

확정 응답에는 아래 필드도 함께 옵니다(기존 필드의 이름·의미는 그대로).

필드설명
methodKCP · 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"}} 형태입니다.

HTTPcode의미
400TX_ID_MISMATCHtxId 가 이 세션 것이 아님
400SANDBOX_NOT_ALLOWED_IN_LIVElive 모드에 sandbox 파라미터
400INVALID_SANDBOX_BIRTHDAY / REQUEST_URL_REQUIRED입력 오류
400INVALID_METHOD / METHOD_NOT_SUPPORTEDmethod 가 DOCUMENT 가 아니거나, 한국 요청에 method 를 줌
400COUNTRY_NOT_SUPPORTED해외(country ≠ KR) 인증이 지금 열려 있지 않음
403USER_REF_MISMATCH확정 요청의 userRef 가 세션 소유자와 다름
403—identity.verify 권한 없음 또는 앱에 identity 서비스 꺼짐
404VERIFICATION_NOT_FOUND없거나, 다른 앱의 세션이거나, 다른 모드(test↔live) 키로 조회 — 모두 같은 응답
409VERIFICATION_NOT_COMPLETED사용자가 아직 인증을 끝내지 않음. 잠시 뒤 다시 확정
410VERIFICATION_EXPIRED생성 후 60분 경과. 새 세션을 만드세요
502IDENTITY_PROVIDER_ERROR인증기관 일시 장애. 세션은 유지되니 재시도
503IDENTITY_NOT_CONFIGURED해당 모드의 본인확인이 아직 열리지 않음

보관

CP 는 앱 ID·userRef·결과·출생연도·ciHash(personKey)·방식·나라·추정 나이(정수)·시각만 남깁니다. 해외 인증의 생년월일 전체·신분증 번호·얼굴 사진은 남기지 않습니다. 미완료 기록은 30일, 확인된 기록은 1년 뒤 삭제합니다.