결제 조회는 프론트엔드에서 받은 receipt_id로 Bootpay 결제 내역을 서버에서 다시 조회하고, 내부 주문 정보와 비교하는 단계예요.
프론트엔드의 성공 콜백만으로 주문을 완료 처리하면 안 돼요. 브라우저에서 받은 값은 누락되거나 조작될 수 있으므로, 서버가 Bootpay API로 결제 상태와 금액을 다시 확인해야 해요.
이 문서는 서버에서 receipt_id로 결제 내역을 조회하고 내부 주문 정보와 비교하는 방법을 설명해요.
주문 상태를 done으로 바꾸기 전, 또는 웹훅으로 받은 이벤트를 처리하기 전에 같은 조회 흐름을 사용해요.
핵심 요약
- 결제 조회 API는
receipt_id로 결제 내역을 조회해요. - 주문 완료 조건은 보통 상태가 결제 완료이고, 결제 금액이 내부 주문 금액과 일치하는 것이에요.
receipt_id, 결제 금액, 결제수단, 결제 완료 시각은 주문 DB에 저장해 둬요.- 웹훅을 받았을 때도 웹훅 데이터만 믿지 말고 같은 방식으로 다시 조회해요.
- 금액이나 상태가 맞지 않으면 주문을 완료 처리하면 안 돼요.
API 엔드포인트
https://api.bootpay.co.kr/v2/receipt/{receipt_id}Basic Auth| 파라미터 | 위치 | 필수 | 설명 |
|---|---|---|---|
receipt_id |
Path | 필수 | 결제 완료, 승인 대기, 웹훅 이벤트에서 받은 Bootpay 영수증 ID |
서버 SDK를 사용하면 인증 헤더와 요청 처리를 SDK가 대신 처리해요. 직접 API를 호출할 때는 API 인증을 먼저 확인해요.
언제 조회하나
결제 조회는 서버가 결제 결과를 처음 인지하는 시점마다 수행해요.
| 상황 | 서버가 할 일 |
|---|---|
프론트엔드 onDone 수신 |
클라이언트 자동 승인 흐름에서 receipt_id를 받아 결제 조회 후 주문 완료 처리 |
프론트엔드 onConfirm 수신 |
서버 승인 흐름에서 승인 전 금액과 주문 정보를 확인한 뒤 승인 처리. 이 경우 onDone은 호출되지 않음 |
| 웹훅 수신 | 웹훅의 receipt_id로 다시 조회한 뒤 주문 상태 보정 |
| 취소·환불 처리 전 | 저장된 주문 상태와 결제 상태를 확인한 뒤 취소 API 호출 |
웹훅 데이터도 그대로 믿으면 안 돼요.
웹훅은 상태 변경을 알려주는 신호이고, 최종 처리는 receipt_id 조회 결과와 내부 주문 정보를 비교한 뒤 진행해요.
기본 조회 흐름
| 순서 | 위치 | 내용 |
|---|---|---|
| 1 | 프론트엔드 | 클라이언트 자동 승인 흐름에서는 done, 서버 승인 흐름에서는 confirm으로 receipt_id를 받는다 |
| 2 | 프론트엔드 → 서버 | receipt_id와 내부 order_id를 서버로 보낸다 |
| 3 | 서버 | 내부 주문을 조회해 예상 금액과 상태를 확인해요 |
| 4 | 서버 → Bootpay | GET /v2/receipt/{receipt_id}를 호출한다 |
| 5 | 서버 | 응답의 금액, 상태, 주문번호를 내부 주문과 비교한다 |
| 6 | 서버 | 조회 결과가 내부 주문과 일치하면 주문 상태를 done으로 저장한다 |
| 7 | 서버 | 실패 시 주문을 완료 처리하지 않고 실패 사유를 기록한다 |
조회 후 확인해야 할 값
최소한 아래 값은 비교해요.
| 값 | 확인 기준 |
|---|---|
status |
일반 결제 완료는 1인지 확인해요 |
price |
내부 주문 금액과 일치하는지 확인해요 |
order_id |
내부 주문 ID와 같은 결제 건인지 확인해요 |
receipt_id |
이미 처리한 영수증인지 확인해 중복 처리를 막는다 |
currency |
서비스가 기대한 통화인지 확인해요 |
method_symbol |
허용한 결제수단인지 확인해요 |
분리 승인 흐름에서는 승인 전 상태를 확인해야 하므로 일반 결제 완료와 기준이 다를 수 있어요. 서버 승인 방식은 분리 승인과 결제 흐름 설계를 함께 확인해요.
코드 예제
아래 예제의 핵심은 SDK 호출이 아니라 내부 주문과 Bootpay 조회 결과를 비교한 뒤 주문 상태를 바꾸는 것이에요.
import { Bootpay } from '@bootpay/backend-js'
Bootpay.setConfiguration({
client_key: process.env.BOOTPAY_CLIENT_KEY,
secret_key: process.env.BOOTPAY_SECRET_KEY,
})
export async function verifyPayment({ orderId, receiptId }) {
const order = await db.orders.findByPk(orderId)
if (!order) throw new Error('주문을 찾을 수 없습니다')
if (order.status === 'done') return order
const receipt = await Bootpay.receiptPayment(receiptId)
if (receipt.status !== 1) {
throw new Error('결제 완료 상태가 아닙니다')
}
if (receipt.price !== order.amount) {
throw new Error('결제 금액이 주문 금액과 다릅니다')
}
if (receipt.order_id && receipt.order_id !== order.order_id) {
throw new Error('주문번호가 일치하지 않습니다')
}
await db.orders.update({
bootpay_receipt_id: receipt.receipt_id,
paid_amount: receipt.price,
payment_method: receipt.method_symbol,
payment_status: receipt.status,
status: 'done',
paid_at: receipt.purchased_at ? new Date(receipt.purchased_at) : new Date(),
}, { where: { id: order.id } })
return db.orders.findByPk(order.id)
}javascriptimport os
from bootpay_backend import BootpayBackend
bootpay = BootpayBackend(
client_key=os.environ['BOOTPAY_CLIENT_KEY'],
secret_key=os.environ['BOOTPAY_SECRET_KEY'],
)
def verify_payment(order_id, receipt_id):
order = Order.get(order_id)
if order.status == 'done':
return order
receipt = bootpay.receipt_payment(receipt_id)
if receipt['status'] != 1:
raise Exception('결제 완료 상태가 아닙니다')
if receipt['price'] != order.amount:
raise Exception('결제 금액이 주문 금액과 다릅니다')
order.bootpay_receipt_id = receipt['receipt_id']
order.paid_amount = receipt['price']
order.payment_method = receipt.get('method_symbol')
order.status = 'done'
order.paid_at = receipt.get('purchased_at')
order.save()
return orderpython$receipt = BootpayApi::receiptPayment($receiptId);
$order = Order::find($orderId);
if (!$order) {
throw new Exception('주문을 찾을 수 없습니다');
}
if ($order->status === 'done') {
return $order;
}
if ($receipt['status'] !== 1) {
throw new Exception('결제 완료 상태가 아닙니다');
}
if ($receipt['price'] !== $order->amount) {
throw new Exception('결제 금액이 주문 금액과 다릅니다');
}
$order->update([
'bootpay_receipt_id' => $receipt['receipt_id'],
'paid_amount' => $receipt['price'],
'payment_method' => $receipt['method_symbol'] ?? null,
'payment_status' => $receipt['status'],
'status' => 'done',
'paid_at' => $receipt['purchased_at'] ?? date('c'),
]);phpvar order = orderRepository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다"));
if ("done".equals(order.getStatus())) {
return order;
}
var receipt = bootpay.getReceipt(receiptId);
int status = ((Number) receipt.get("status")).intValue();
double price = ((Number) receipt.get("price")).doubleValue();
if (status != 1) {
throw new IllegalStateException("결제 완료 상태가 아닙니다");
}
if (price != order.getAmount()) {
throw new IllegalStateException("결제 금액이 주문 금액과 다릅니다");
}
order.setBootpayReceiptId((String) receipt.get("receipt_id"));
order.setPaidAmount(price);
order.setPaymentStatus(status);
order.setStatus("done");
orderRepository.save(order);javaorder = Order.find(order_id)
return order if order.status == 'done'
receipt = bootpay.verify(receipt_id).data
raise '결제 완료 상태가 아닙니다' unless receipt['status'] == 1
raise '결제 금액이 주문 금액과 다릅니다' unless receipt['price'] == order.amount
order.update(
bootpay_receipt_id: receipt['receipt_id'],
paid_amount: receipt['price'],
payment_method: receipt['method_symbol'],
payment_status: receipt['status'],
status: 'done',
paid_at: receipt['purchased_at'] || Time.now
)rubyorder, err := findOrder(orderID)
if err != nil {
return err
}
if order.Status == "done" {
return nil
}
receipt, err := api.GetReceipt(receiptID)
if err != nil {
return err
}
status := int(receipt["status"].(float64))
price := receipt["price"].(float64)
bootpayReceiptID := receipt["receipt_id"].(string)
if status != 1 {
return errors.New("결제 완료 상태가 아닙니다")
}
if price != order.Amount {
return errors.New("결제 금액이 주문 금액과 다릅니다")
}
return markOrderPaid(orderID, bootpayReceiptID, price)govar order = await orderRepository.FindAsync(orderId);
if (order == null) throw new Exception("주문을 찾을 수 없습니다");
if (order.Status == "done") return order;
var response = await bootpay.GetReceipt(receiptId);
var body = await response.Content.ReadAsStringAsync();
var receipt = JsonConvert.DeserializeObject<Dictionary<string, object>>(body);
var status = Convert.ToInt32(receipt["status"]);
var price = Convert.ToDouble(receipt["price"]);
if (status != 1) {
throw new Exception("결제 완료 상태가 아닙니다");
}
if (price != order.Amount) {
throw new Exception("결제 금액이 주문 금액과 다릅니다");
}
order.BootpayReceiptId = receipt["receipt_id"].ToString();
order.PaidAmount = price;
order.PaymentStatus = status;
order.Status = "done";
await orderRepository.SaveAsync(order);csharp응답 예시
결제 조회 응답은 결제 상태에 따라 status와 일부 필드가 달라져요. 주문 확정은 보통 status: 1일 때만 처리하고, 나머지는 대기·취소·실패 상태로 분기해요.
{
"receipt_id": "6244f60c1fc19202e42e8c4e",
"order_id": "order_20260428_001",
"price": 1000,
"tax_free": 0,
"cancelled_price": 0,
"cancelled_tax_free": 0,
"order_name": "결제 테스트 상품",
"company_name": "부트페이",
"sandbox": true,
"pg": "kcp",
"method": "card",
"method_symbol": "card",
"currency": "KRW",
"status": 1,
"status_locale": "결제완료",
"purchased_at": "2026-04-28T09:30:29+09:00",
"requested_at": "2026-04-28T09:30:04+09:00"
}json{
"receipt_id": "6244f60c1fc19202e42e8c4e",
"order_id": "order_20260428_001",
"price": 1000,
"order_name": "결제 테스트 상품",
"pg": "kcp",
"method": "card",
"method_symbol": "card",
"currency": "KRW",
"status": 2,
"status_locale": "입금/승인대기",
"requested_at": "2026-04-28T09:30:04+09:00"
}json{
"receipt_id": "6244f60c1fc19202e42e8c4e",
"order_id": "order_20260428_001",
"price": 1000,
"order_name": "무통장 입금 주문",
"pg": "kcp",
"method": "vbank",
"method_symbol": "가상계좌",
"currency": "KRW",
"status": 5,
"status_locale": "가상계좌발급완료",
"vbank_data": {
"bank_name": "국민은행",
"account": "1234567890",
"account_holder": "부트페이",
"expired_at": "2026-04-29T23:59:59+09:00"
},
"requested_at": "2026-04-28T09:30:04+09:00"
}json{
"receipt_id": "6244f60c1fc19202e42e8c4e",
"order_id": "order_20260428_001",
"price": 1000,
"cancelled_price": 1000,
"order_name": "결제 테스트 상품",
"pg": "kcp",
"method": "card",
"method_symbol": "card",
"currency": "KRW",
"status": 20,
"status_locale": "결제취소완료",
"cancelled_at": "2026-04-28T09:35:12+09:00",
"purchased_at": "2026-04-28T09:30:29+09:00"
}json{
"error_code": "RC_NOT_FOUND",
"message": "영수증 정보를 찾지 못했습니다."
}json결제창과 결제위젯의 프론트엔드 이벤트 데이터는 receipt_id를 받기 위한 최소 데이터예요. 주문 확정에 필요한 기준 JSON은 서버에서 GET /v2/receipt/{receipt_id}로 다시 조회한 이 응답이에요. 결제창, 결제위젯, 브랜드페이, 웹훅 처리 모두 같은 조회 응답을 기준으로 status, price, order_id를 내부 DB 값과 비교해요.
주문 완료 판단에 자주 쓰는 필드는 아래와 같아요.
| 필드 | 저장 여부 | 용도 |
|---|---|---|
receipt_id |
필수 | 결제 조회, 취소, 웹훅 중복 처리 기준 |
order_id |
필수 | 내부 주문과 Bootpay 결제 건 연결 |
price |
필수 | 결제 금액 기록과 금액 일치 여부 확인 |
status |
필수 | 결제 완료, 취소, 승인 대기 상태 판단 |
method_symbol |
권장 | 결제수단 표시와 운영 분석 |
pg |
권장 | PG별 장애·정산 확인 |
purchased_at |
권장 | 결제 완료 시각 기록 |
cancelled_price |
권장 | 부분 취소 이후 남은 금액 계산 |
결제 status 값
결제 조회 응답의 status는 Bootpay 결제 상태예요. 주문 상태 문자열로 그대로 복사하기보다, 서버 모델의 receipt_status 상수와 status_locale 라벨을 기준으로 내부 주문 상태를 따로 전이해요.
| status | 상태 의미 | 서버 처리 기준 |
|---|---|---|
0 |
결제대기 | 아직 주문 완료 처리하지 않아요 |
1 |
결제완료 | 금액·주문번호까지 맞을 때만 주문을 완료 처리해요 |
2 |
입금/승인대기 | 서버 승인 흐름에서는 승인 API 호출 전 검증 기준으로, 일부 입금 대기 흐름에서는 대기 상태로 사용해요 |
3 |
결제승인중 | 잠시 뒤 다시 조회하거나 웹훅으로 보정해요 |
4 |
결제진행중 | 주문 완료 처리하지 않고 진행 상태로 둬요 |
5 |
가상계좌발급완료 | 입금 대기 상태로 저장하고, 입금 완료 웹훅(status: 1)에서 확정해요 |
6 |
중간 Blank 진입 상태 | 내부 중간 상태로 보고 주문 완료 처리하지 않아요 |
7 |
결제승인지연중 | 재조회 또는 웹훅으로 최종 상태를 보정해요 |
10 |
아이템 View | 결제 완료 기준으로 사용하지 않아요 |
11 |
빌링키발급완료 | 자동결제·예약결제용 빌링키 저장 흐름에서 사용해요 |
12 |
본인인증완료 | 본인인증 흐름에서만 완료 기준으로 사용해요 |
20 |
결제취소완료 | 내부 주문·환불 상태를 취소로 보정해요 |
21 |
취소처리지연중 | 최종 취소 완료 웹훅 또는 재조회 결과로 보정해요 |
30 |
결제취소진행중 | 최종 취소 완료 웹훅 또는 재조회 결과로 보정해요 |
40 |
자동결제준비 | 빌링키 발급 준비 상태로 보고 완료 처리하지 않아요 |
41 |
자동결제빌링키발급이전 | 추가 발급·확정 단계가 끝난 뒤 빌링키를 저장해요 |
60 |
현금영수증발행완료 | 현금영수증 부가 상태예요 |
61 |
현금영수증발행취소 | 현금영수증 부가 상태예요 |
-1 |
결제실패 | 실패 사유를 기록하고 주문 완료 처리하지 않아요 |
-2 |
결제승인실패 | 승인 실패로 기록하고 재시도/고객 안내를 처리해요 |
-3 |
가상계좌발급취소 | 입금 대기 주문을 취소 또는 만료 상태로 보정해요 |
-4 |
결제요청실패 | 결제 요청 실패로 기록해요 |
-5 |
승인치명적인오류 | 운영자가 확인할 수 있도록 로그와 알림을 남겨요 |
-11 |
빌링키발급취소 | 저장 결제수단 등록 실패 또는 해지로 처리해요 |
-15 |
닫힘 | 결제창 이탈로 보고 주문 완료 처리하지 않아요 |
-16 |
결제시간만료 | 결제 시간 만료로 기록하고 주문 완료 처리하지 않아요 |
-17 |
결제금액변조승인 | 금액 변조 의심 건으로 주문 완료 처리하지 않고 운영자가 확인해요 |
-20 |
결제취소실패 | 취소 재시도 또는 수동 확인 대상으로 남겨요 |
-21 |
치명적인 취소 실패 | 운영자가 확인할 수 있도록 로그와 알림을 남겨요 |
-30 |
결제취소진행중 | 취소 진행 상태로 보고 최종 결과를 다시 확인해요 |
-40 |
자동결제빌링키발급실패 | 빌링키 발급 실패로 기록하고 고객에게 재등록을 안내해요 |
-60 |
현금영수증발행실패 | 현금영수증 부가 상태예요 |
-61 |
현금영수증취소실패 | 현금영수증 부가 상태예요 |
-100 |
결제창닫힘 | 결제가 진행되지 않은 이탈 상태로 처리해요 |
DB 스키마는 데이터 모델 설계를 참고해요.
조회 결과 불일치 처리
조회 결과가 내부 주문 정보와 맞지 않으면 주문을 완료 처리하면 안 돼요. 실패 사유를 기록하고, 필요한 경우 취소 또는 고객 안내 흐름으로 보내요.
| 실패 상황 | 처리 |
|---|---|
receipt_id가 없음 |
잘못된 요청으로 처리해요 |
| 결제 내역을 찾을 수 없음 | 프론트엔드 요청 값 또는 웹훅 값을 다시 확인해요 |
| 금액 불일치 | 주문 완료 처리하지 않고 결제 취소를 검토한다 |
| 결제 미완료 상태 | 대기 또는 실패 상태로 저장한다 |
이미 처리한 receipt_id |
멱등 처리하고 중복 저장하면 안 돼요 |
금액이 다르거나 상태가 완료가 아닌 결제 건은 주문을 done으로 바꾸면 안 돼요.
주문 상태 변경은 반드시 서버 조회와 내부 주문 확인이 끝난 뒤 한 번만 수행해요.
에러 코드
인증·권한 관련 에러는 에러 코드표를 참고해요.
| 코드 | 메시지 | 대처 방법 |
|---|---|---|
RC_NOT_FOUND |
영수증 정보를 찾지 못했습니다 | receipt_id가 올바른지 확인해요 |
TOKEN_KEY_INVALID |
인증 토큰이 유효하지 않다 | 서버 인증 정보를 확인해요 |
APP_KEY_CHAIN_SESSION_INVALID |
Client Key 접근 권한 없음 | client_key와 secret_key가 올바른지 확인해요 |
