이 문서는 백엔드에서 receipt_id로 본인인증 정보를 조회하고, 완료 여부를 검증하고, 필요한 값만 저장하는 방법을 설명해요.
인증창이 닫혔다는 것과 인증이 성공했다는 것은 다른 이야기예요. 클라이언트가 보낸 done 이벤트는 신호일 뿐이고, 실제 판단은 서버가 조회한 status 값으로 해요.
핵심 요약
GET /certificate/{receipt_id}로 인증 내역을 조회해요. 인증 방식은 결제 API와 같은 Basic Auth예요.status가12(본인인증완료) 일 때만 인증 완료로 처리해요. 다른 값은 전부 미완료예요.- 조회는 승인 완료 후 30분 안에 해야 해요. 30분이 지나면 조회 API가
AUTH_EXPIRED로 차단돼요. authenticate_data.unique는 CI(연계정보),authenticate_data.di는 DI(중복가입 확인정보)예요. 우리 서비스 안의 중복 가입 방지에는di를 써요.- 클라이언트가 보낸 이름·전화번호는 저장하지 않아요. 반드시 조회 응답값을 저장해요.
API 정보
https://api.bootpay.co.kr/v2/certificate/{receipt_id}Basic Auth인증 헤더는 API 인증과 같은 Basic Auth(client_key:secret_key) 방식이고, 서버 SDK에 키를 설정하면 자동으로 처리돼요.
조회·검증 코드
import { Bootpay } from '@bootpay/backend-js'
Bootpay.setConfiguration({
client_key: '[ Client Key ]',
secret_key: '[ Secret Key ]'
})
app.post('/auth/certificate', async (req, res) => {
const { receipt_id } = req.body
const certificate = await Bootpay.certificate(receipt_id)
// status 12(본인인증완료)일 때만 인증 완료로 처리한다.
if (certificate.status !== 12) {
return res.status(400).json({ message: '본인인증이 완료되지 않았습니다.' })
}
// 조회 응답값만 저장한다. 클라이언트가 보낸 값은 신뢰하지 않는다.
// unique = CI(기관 공통), di = DI(가맹점별)
const data = certificate.authenticate_data
await saveVerifiedUser({
name: data.name,
birth: data.birth,
ci: data.unique,
di: data.di
})
return res.json({ verified: true })
})javascriptfrom bootpay_backend import BootpayBackend
bootpay = BootpayBackend(
client_key='[ Client Key ]',
secret_key='[ Secret Key ]',
)
@app.route('/auth/certificate', methods=['POST'])
def auth_certificate():
receipt_id = request.get_json().get('receipt_id')
certificate = bootpay.certificate(receipt_id)
# status 12(본인인증완료)일 때만 인증 완료로 처리한다.
if certificate.get('status') != 12:
return jsonify(message='본인인증이 완료되지 않았습니다.'), 400
# 조회 응답값만 저장한다. 클라이언트가 보낸 값은 신뢰하지 않는다.
# unique = CI(기관 공통), di = DI(가맹점별)
data = certificate.get('authenticate_data')
save_verified_user(
name=data.get('name'),
birth=data.get('birth'),
ci=data.get('unique'),
di=data.get('di')
)
return jsonify(verified=True)python$receipt_id = json_decode(file_get_contents('php://input'), true)['receipt_id'];
BootpayApi::setClientKeyConfiguration('[ Client Key ]', '[ Secret Key ]');
$certificate = BootpayApi::certificate($receipt_id);
// status 12(본인인증완료)일 때만 인증 완료로 처리한다.
if ($certificate->status !== 12) {
http_response_code(400);
echo json_encode(['message' => '본인인증이 완료되지 않았습니다.']);
exit;
}
// 조회 응답값만 저장한다. 클라이언트가 보낸 값은 신뢰하지 않는다.
// unique = CI(기관 공통), di = DI(가맹점별)
$data = $certificate->authenticate_data;
saveVerifiedUser($data->name, $data->birth, $data->unique, $data->di);
echo json_encode(['verified' => true]);php@PostMapping("/auth/certificate")
public ResponseEntity<?> authCertificate(@RequestBody Map<String, String> body) throws Exception {
String receiptId = body.get("receipt_id");
Bootpay bootpay = Bootpay.withClientKey("[ Client Key ]", "[ Secret Key ]");
HashMap<String, Object> certificate = bootpay.certificate(receiptId);
// status 12(본인인증완료)일 때만 인증 완료로 처리한다.
int status = ((Number) certificate.get("status")).intValue();
if (status != 12) {
return ResponseEntity.badRequest().body(Map.of("message", "본인인증이 완료되지 않았습니다."));
}
// 조회 응답값만 저장한다. 클라이언트가 보낸 값은 신뢰하지 않는다.
// unique = CI(기관 공통), di = DI(가맹점별)
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) certificate.get("authenticate_data");
saveVerifiedUser(data.get("name"), data.get("birth"), data.get("unique"), data.get("di"));
return ResponseEntity.ok(Map.of("verified", true));
}javapost '/auth/certificate' do
receipt_id = JSON.parse(request.body.read)['receipt_id']
bootpay = Bootpay::Api.new(client_key: '[ Client Key ]', secret_key: '[ Secret Key ]')
certificate = bootpay.certificate(receipt_id).data
# status 12(본인인증완료)일 때만 인증 완료로 처리한다.
if certificate['status'] != 12
halt 400, { message: '본인인증이 완료되지 않았습니다.' }.to_json
end
# 조회 응답값만 저장한다. 클라이언트가 보낸 값은 신뢰하지 않는다.
# unique = CI(기관 공통), di = DI(가맹점별)
data = certificate['authenticate_data']
save_verified_user(name: data['name'], birth: data['birth'], ci: data['unique'], di: data['di'])
{ verified: true }.to_json
endrubyfunc authCertificate(w http.ResponseWriter, r *http.Request) {
var body map[string]string
json.NewDecoder(r.Body).Decode(&body)
receiptId := body["receipt_id"]
api := bootpay.NewAPIWithClientKey("[ Client Key ]", "[ Secret Key ]", nil, "")
certificate, err := api.Certificate(receiptId)
if err != nil {
http.Error(w, "인증 정보를 조회하지 못했습니다.", http.StatusBadRequest)
return
}
// status 12(본인인증완료)일 때만 인증 완료로 처리한다.
status := int(certificate["status"].(float64))
if status != 12 {
http.Error(w, "본인인증이 완료되지 않았습니다.", http.StatusBadRequest)
return
}
// 조회 응답값만 저장한다. 클라이언트가 보낸 값은 신뢰하지 않는다.
// unique = CI(기관 공통), di = DI(가맹점별)
data := certificate["authenticate_data"].(map[string]interface{})
saveVerifiedUser(data["name"], data["birth"], data["unique"], data["di"])
json.NewEncoder(w).Encode(map[string]bool{"verified": true})
}go[HttpPost("/auth/certificate")]
public async Task<IActionResult> AuthCertificate([FromBody] Dictionary<string, string> body)
{
var receiptId = body["receipt_id"];
var bootpay = BootpayApi.WithClientKey("[ Client Key ]", "[ Secret Key ]");
var response = await bootpay.Certificate(receiptId);
var certificate = JsonConvert.DeserializeObject<Dictionary<string, object>>(
await response.Content.ReadAsStringAsync()
);
// status 12(본인인증완료)일 때만 인증 완료로 처리한다.
if (Convert.ToInt32(certificate["status"]) != 12)
{
return BadRequest(new { Message = "본인인증이 완료되지 않았습니다." });
}
// 조회 응답값만 저장한다. 클라이언트가 보낸 값은 신뢰하지 않는다.
// unique = CI(기관 공통), di = DI(가맹점별)
var data = (JObject)certificate["authenticate_data"];
SaveVerifiedUser(data["name"], data["birth"], data["unique"], data["di"]);
return Ok(new { Verified = true });
}csharp응답
{
"receipt_id": "6244f60c1fc19202e42e8c4e",
"authentication_id": "auth_1718000000000",
"gateway_url": "https://gw.bootpay.co.kr",
"metadata": {},
"pg": "다날",
"method": "인증",
"method_symbol": "auth",
"method_origin": "인증",
"method_origin_symbol": "auth",
"status": 12,
"status_locale": "본인인증완료",
"requested_at": "2026-06-11T09:30:01+09:00",
"authenticated_at": "2026-06-11T09:30:29+09:00",
"authenticate_data": {
"name": "홍길동",
"phone": "01012345678",
"birth": "19900101",
"gender": 1,
"foreigner": "0",
"carrier": "SKT",
"unique": "[ CI 값 ]",
"di": "[ DI 값 ]",
"tid": "danal_tid_..."
}
}json| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
receipt_id |
String | 필수 | 부트페이 인증 거래 ID |
authentication_id |
String | 필수 | 요청 시 보낸 가맹점 고유 인증번호 |
gateway_url |
String | 필수 | 인증을 처리한 게이트웨이 URL |
metadata |
Object | 선택 | 요청 시 보낸 커스텀 데이터. 없으면 {} |
pg |
String | 필수 | 인증을 처리한 PG |
method |
String | 필수 | 인증 수단. 본인인증 건은 인증 |
method_symbol |
String | 선택 | 인증 수단 코드. 본인인증 건은 auth |
method_origin |
String | 선택 | PG가 내려준 원본 수단명. 본인인증 건은 인증 |
method_origin_symbol |
String | 선택 | 원본 수단 코드. 본인인증 건은 auth |
status |
Integer | 필수 | 인증 상태. 12(본인인증완료)일 때만 완료로 처리해요 |
status_locale |
String | 선택 | 상태의 한글 표기. 한글 라벨이 없는 상태(50·51·-50·-12)에서는 키 자체가 빠져요 |
requested_at |
Datetime | 필수 | 인증 요청 시각 |
authenticated_at |
Datetime | 선택 | 인증 완료 시각. 미완료 건은 비어 있어요 |
cancelled_at |
Datetime | 선택 | 취소 시각. 값이 없으면 키가 빠져요 |
authenticate_data |
Object | 필수 | 인증된 사용자 정보 |
name |
String | 선택 | 이름 |
phone |
String | 선택 | 휴대폰 번호 |
birth |
String | 선택 | 생년월일. PG 원본 형식 그대로 전달돼요 (예: 다날 19900101) |
gender |
Integer | 선택 | 성별 (1: 남자, 0: 여자) |
foreigner |
- | 선택 | 내·외국인 구분값. PG마다 타입이 달라요 (다날은 원본 문자열 "0"/"1", KCP는 Integer 0/1) |
carrier |
String | 선택 | 통신사 (예: SKT) |
unique |
String | 선택 | CI(연계정보). 기관 공통 식별값이라 어느 가맹점에서 인증해도 같은 값이에요 |
di |
String | 선택 | DI(중복가입 확인정보). 가맹점별 식별값이라 중복 가입 방지에 써요 |
tid |
String | 필수 | PG 거래 ID |
12가 본인인증완료예요. 결제 상태값과 같은 체계를 쓰기 때문에 전체 목록은 결제 조회의 상태표에서 확인할 수 있어요. 본인인증 흐름에서는 12 외의 값을 모두 미완료로 처리하면 돼요.
foreigner는 PG가 내려주는 내·외국인 구분값이에요. 다날은 PG 원본 문자열("0" / "1")이 그대로 전달되고, KCP는 Integer(0 / 1)로 변환돼요. 서버 분기 조건으로 쓴다면 타입까지 함께 비교하거나 문자열로 정규화한 뒤 판단해요.
인증 정보 저장 설계
30분 안에 저장해야 해요
조회 API는 승인 완료 시각(authenticated_at)부터 30분이 지나면 AUTH_EXPIRED로 차단돼요. 그래서 "나중에 필요하면 receipt_id로 다시 조회한다"는 설계는 동작하지 않아요. 인증 직후 조회해서 필요한 값만 내 DB에 저장해요.
30분이 지난 뒤 조회하면 AUTH_EXPIRED 에러가 반환되고, 사용자는 처음부터 다시 인증해야 해요.
CI와 DI를 구분해서 써요
authenticate_data에는 식별값이 두 개 들어와요. 이름이 헷갈리기 쉬우니 용도를 나눠서 저장해요.
| 필드 | 정체 | 범위 | 주 용도 |
|---|---|---|---|
unique |
CI(연계정보) | 기관 공통. 어느 가맹점에서 인증해도 같은 값 | 동일인 식별, 타 서비스 연계 |
di |
DI(중복가입 확인정보) | 가맹점(사이트)별. 같은 사람이라도 가맹점마다 다른 값 | 우리 서비스 안의 중복 가입 방지 |
- 휴대폰 번호가 바뀌어도 DI가 같으면 우리 서비스에서 같은 사람이에요. 번호나 이름으로 동일인을 판단하면 안 돼요.
- 회원 테이블에 DI를 저장해 두고, 신규 가입 시 같은 DI가 이미 있으면 중복 가입으로 처리해요.
- CI·DI 모두 개인 식별정보예요. 평문 노출을 피하고 암호화 저장하며, 조회용으로만 쓰도록 접근을 제한해요.
// 중복 가입 방지 예시
const certificate = await Bootpay.certificate(receipt_id)
if (certificate.status !== 12) throw new Error('본인인증 미완료')
const { unique: ci, di, name, birth } = certificate.authenticate_data
const existing = await User.findByDi(di) // 중복 가입 판단은 DI로
if (existing) {
// 이미 가입된 사람 — 아이디 찾기·계정 통합 안내로 유도한다.
return { duplicated: true, maskedEmail: mask(existing.email) }
}
await User.create({ ci, di, name, birth })javascript저장은 최소한으로
authenticate_data에는 이름·생년월일·휴대폰 번호 같은 개인정보가 들어 있어요. 서비스에 실제로 필요한 값만 저장하고, 나머지는 저장하지 않아요.
- 성인 인증만 필요하다면 → 생년월일 대신 성인 여부(boolean) 만 저장하는 것도 방법이에요.
- 중복 가입 방지만 필요하다면 →
di(DI)만 저장하면 충분해요. - 저장한 항목은 개인정보 처리방침의 수집 항목·보관 기간과 일치시켜요.
에러 코드
| 코드 | 상황 | 처리 |
|---|---|---|
AUTH_NOT_FOUND (2407) |
인증 정보를 찾지 못했어요 | receipt_id가 올바른지 확인해요 |
AUTH_NOT_CONFIRMED (2408) |
아직 승인(status: 12)되지 않은 건이에요 |
사용자 인증 완료 후 다시 조회해요 |
AUTH_EXPIRED (2409) |
승인 완료 후 30분이 지나 조회할 수 없어요 | 인증을 처음부터 다시 요청해요 |
RC_NOT_AUTH (2064) |
본인인증 건이 아니에요 (결제·정기결제 영수증) | 본인인증으로 발급된 receipt_id인지 확인해요 |
API_ONLY_SELLER (600) |
판매점 계정만 호출할 수 있어요 | 연동키가 판매점 프로젝트의 것인지 확인해요 |
APP_KEY_CHAIN_SESSION_INVALID (1225) |
연동키 세션이 해당 인증 건과 맞지 않아요 | 인증을 요청할 때 쓴 것과 같은 프로젝트 연동키로 조회해요 |
전체 목록은 에러 코드표에서 확인해요.