Identity Verification API (age check)

One CP API in front of identity providers. Users in Korea go through a mobile identity check (NHN KCP); users elsewhere go through facial age estimation or ID document verification (Didit) — your app only passes country. Your server calls it twice with an API key and gets back only whether the user is an adult and a value that identifies the same person within your app.

  • Name, phone number, gender, raw CI/DI, full date of birth, document number and face images are never sent to your app, and CP does not store them.
  • Do not call it from the browser. Keep the API key on your server.

Permissions

  • Your app must have the identity service enabled.
  • The API key needs the identity.verify permission (identity.* also works). pay.* does not grant it.
  • After enabling the service, issue a new key. Existing keys keep their original permissions.

Test and live modes

The mode is live only when the key is live and the service is approved for live identity verification. Everything else is test. Identity live approval is separate from payment live approval (a service whose payments are still in test can be approved for live identity only); a service approved for live payments is also live for identity.

ModeBehavior
testDeterministic sandbox. No real verification window; sandbox.birthday picks adult or minor.
liveA real identity check. The response provider tells you which window to open (portone, toss_cert or didit). Sending sandbox parameters returns 400.

Overseas requests (country other than KR) go to the Didit sandbox in test mode. Do not send sandbox parameters for them — if you do, the request goes to the deterministic sandbox above regardless of country. Pick the outcome (approved, in review, declined) with "Select test result" on the Didit screen; the selfie step needs a real camera.

Check availability

You don't need to create a session to decide whether to turn your gate on. This tells you, without creating anything, whether this key can complete a verification right now.

GET /identity/availability?country=AU
X-API-Key: <key with identity.verify>

Pass country (ISO-3166 alpha-2, optional) to get the answer for users in that country. Without it the answer is for Korea.

{ "mode": "live", "provider": "portone", "sandbox": false, "available": false }

With available: false, creating or confirming a verification ends in 503 IDENTITY_NOT_CONFIGURED. Keep your existing age check until it turns true, then switch to the verification gate. In production, don't treat sandbox: true as available.

1. Create a verification

POSThttps://pay.cp-platform.com/identity/verifications
Authentication
API Key
Permission
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" }
}
FieldDescription
userRefYour user identifier (required, up to 128 chars). Kept in the audit record.
successUrl / failUrlWhere to return after verification. https or an app scheme (yourapp://...).
requestUrlToss requestUrl. Defaults to successUrl (in live mode one of the two is required).
sandboxTest mode only. birthday (YYYYMMDD, default 19900101), outcome (success/fail), personKey (same value gives the same ciHash, default 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"
}

The client opens the Toss standard window with authUrl and 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>

In test mode authUrl is successUrl?txId=…&verificationId=…. Sending the user there simulates a completed verification.

When provider is "portone"

authUrl is null; pass sdk.params as is to the PortOne browser SDK. storeId and channelKey are public values and safe in the browser (the secret stays on CP servers). identityVerificationId is generated by CP and equals txId — do not create your own on the client.

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); // canceled / failed
  // The verdict is server-side only: your server calls the CP confirm API (the SDK response is not trusted)
  await fetch("/api/identity/confirm", { method: "POST", body: JSON.stringify({ verificationId, txId }) });
</script>

On mobile web the user returns to redirectUrl (= successUrl); confirm the same way. On desktop the KCP window opens as a popup, so call requestIdentityVerification when the user clicks a button (calling it automatically gets the popup blocked and fails right away).

Users outside Korea (country ≠ KR) — provider: "didit"

Verification outside Korea is not open yet. A country other than KR returns 400 COUNTRY_NOT_SUPPORTED, and the availability check (?country=) reports available: false. The steps below apply once it opens.

Add country to the create request. Without it, or with KR, the Korean flow above is unchanged.

request
{ "userRef": "user_123", "country": "AU", "successUrl": "https://your-app.example/identity/done" }
FieldDescription
countryThe user's country of residence (ISO-3166 alpha-2). Wrong format returns 422
methodOverseas only; only "DOCUMENT" is accepted. Use it to start a document session after an age estimate falls short (400 METHOD_NOT_SUPPORTED for KR)

The method is fixed per country.

CountryMethod (method)What the user does
AUAGE_ESTIMATIONFacial age estimate from a selfie. Estimates of 15–25 switch to an ID document check on the same screen
OthersDOCUMENTPhoto of an ID document + selfie (face match)

The response authUrl is the Didit screen and sdk is null. Send the user there (or open it in a web view). They come back to successUrl, and you confirm on your server exactly as for Korea.

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": "…" }
  • On a desktop browser the screen shows a QR code and the user continues on their phone (a camera is required). Tell users this on your screen.
  • Don't ask users to type a two-letter code for their country — use a country picker with localized names, prefilled from location or sign-up country.
  • ★Decide the country on your server (location, sign-up country, store region) so users in Korea cannot pick another country to skip the mobile identity check. The country in the confirm response just echoes what you sent — it is not a verified country of residence.

2. Confirm the result

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

userRef is optional but recommended. When sent, it must match the user the verification was created for, otherwise 403 USER_REF_MISMATCH (blocks confirming another user's session).

200 response
{
  "verificationId": "7f0c…",
  "status": "verified",
  "verified": true,
  "isAdult": true,
  "birthYear": 2001,
  "ciHash": "9b1e…(64 hex chars)",
  "verifiedAt": "2026-09-24T03:05:12+00:00",
  "mode": "live",
  "sandbox": false,
  "userRef": "user_123"
}
  • Confirm is idempotent. A settled verification returns the same result again.
  • GET /identity/verifications/{verificationId} reads the stored result.
  • On failure you get verified: false, status: "failed" and a failureCode.

The confirm response also carries these fields (existing fields keep their names and meaning).

FieldDescription
methodKCP, AGE_ESTIMATION or DOCUMENT — what the verdict was actually based on
countryThe country sent when the session was created (echoed as is)
personKey / personKeyKindSame-person value within your app (= ciHash). Kind is KR_CI (mobile CI), FACE or DOC
estimatedAgeLowerAge estimation only: the estimate, rounded down
nextStep"DOCUMENT" when an age estimate falls short — create a new session for the same country with method: "DOCUMENT"

Age estimation results have no birth year (birthYear: null). Because face estimates have an error margin, isAdult is true only for an estimate of 25 or more. Below 25 you get status: "failed", failureCode: "AGE_ESTIMATE_BELOW_THRESHOLD" and nextStep: "DOCUMENT". Document results are decided from the date of birth, so birthYear is present.

How isAdult is decided

It is true from the day the user completes 19 full years (Korea time; people born on 29 February turn a year older on 1 March in non-leap years). Korea's Juvenile Protection Act also treats people who have reached 1 January of the year they turn 19 as adults, so the legal rule is slightly wider. This API takes the stricter side. If you need the calendar-year rule, check birthYear <= currentYear - 19 yourself.

ciHash

For overseas verifications the same field holds a face-based (FACE) or document-based (DOC) value. Values of different kinds differ even for the same person.

An HMAC of the CI with a key that differs per app. Within one app the same person always gets the same value, so you can block duplicate or multiple accounts. Across apps the values differ, so apps cannot link people to each other, and the value cannot be turned back into the CI.

Errors

Error bodies look like {"detail": {"code", "message"}}.

HTTPcodeMeaning
400TX_ID_MISMATCHThe txId does not belong to this verification
400SANDBOX_NOT_ALLOWED_IN_LIVESandbox parameters sent in live mode
400INVALID_SANDBOX_BIRTHDAY / REQUEST_URL_REQUIREDInvalid input
400INVALID_METHOD / METHOD_NOT_SUPPORTEDmethod is not DOCUMENT, or method was sent for Korea
400COUNTRY_NOT_SUPPORTEDVerification outside Korea (country ≠ KR) is not open yet
403USER_REF_MISMATCHThe confirm userRef is not the user the verification was created for
403—Missing identity.verify, or the identity service is not enabled for the app
404VERIFICATION_NOT_FOUNDUnknown, belongs to another app, or looked up with a key of the other mode (test/live). Same response for all
409VERIFICATION_NOT_COMPLETEDThe user has not finished yet. Confirm again shortly
410VERIFICATION_EXPIREDMore than 60 minutes since creation. Start a new one
502IDENTITY_PROVIDER_ERRORTemporary provider failure. The verification is kept, so retry
503IDENTITY_NOT_CONFIGUREDIdentity verification is not open for this mode yet

Retention

CP keeps only the app ID, userRef, result, birth year, ciHash (personKey), method, country, estimated age (whole years) and timestamps. Full dates of birth, document numbers and face images from overseas checks are not kept. Unfinished records are deleted after 30 days and verified records after one year.