이 문서는 서버에서 결제 취소 API를 호출하는 방법을 설명해요. 주문 취소, 고객 환불, 오결제 처리 시 전액 취소와 부분 취소를 서버에서 처리해야 해요.
영상으로 보기
결제 취소하기: 전체취소와 부분취소 · 9분 16초
API 엔드포인트
https://api.bootpay.co.kr/v2/cancelBasic Auth요청 파라미터
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
receipt_id |
String | 필수 | 취소할 결제의 영수증 ID |
cancel_price |
Integer | 선택 | 부분 취소 금액 (미입력 시 전액 취소) |
cancel_tax_free |
Integer | 선택 | 취소 면세 금액. 미입력 시 취소 비율만큼 자동 안분돼요 |
cancel_deposit_price |
Integer | 선택 | 취소할 컵보증금 금액 |
cancel_id |
String | 선택 | 가맹점 취소 고유 ID (중복 방지) |
cancel_username |
String | 선택 | 취소자명 |
cancel_message |
String | 선택 | 취소 사유 |
cancel_requester |
String | 선택 | 취소 요청자 정보 |
refund |
Object | 선택 | 가상계좌 환불 계좌 정보 (bank_code, bank_account, bank_username) |
items |
Array | 선택 | 취소 후 남는 상품 목록. 원스토어 서드파티 연동에서 부분취소할 때는 필수예요 |
cancel_tax_free를 보내지 않으면 서버가 면세금액 × (취소요청금액 ÷ 현재 결제금액) 만큼 자동으로 안분해 취소해요. 면세 금액을 정확히 지정해야 하는 건이라면 직접 값을 넣어요.
cancel_id를 보내지 않으면 서버가 매 요청마다 UUID를 새로 생성해요. 즉 같은 요청을 두 번 보내면 두 번 취소돼요. 네트워크 재시도까지 감안해 중복 취소를 막으려면 가맹점이 직접 cancel_id를 지정해야 해요. 이미 쓴 cancel_id로 다시 요청하면 RC_CANCEL_ID_ALREADY_EXIST(2029)로 거절돼요.
코드 예제
import { Bootpay } from '@bootpay/backend-js'
Bootpay.setConfiguration({
client_key: '[ Client Key ]',
secret_key: '[ Secret Key ]'
})
try {
const response = await Bootpay.cancelPayment({
receipt_id: '[ receipt_id ]',
cancel_price: 1000,
cancel_tax_free: 0,
cancel_id: '[ 가맹점 취소 고유 ID ]',
cancel_username: '홍길동',
cancel_message: '고객 요청에 의한 취소'
})
console.log(response)
} catch (e) {
console.log(e)
}javascriptfrom bootpay_backend import BootpayBackend
bootpay = BootpayBackend(client_key='CLIENT_KEY', secret_key='SECRET_KEY')
response = bootpay.cancel_payment(
receipt_id='[ receipt_id ]',
cancel_price=1000,
cancel_tax_free=0,
cancel_id='[ cancel_id ]',
cancel_username='홍길동',
cancel_message='고객 요청에 의한 취소'
)
print(response)pythonuse Bootpay\ServerPhp\BootpayApi;
BootpayApi::setClientKeyConfiguration('CLIENT_KEY', 'SECRET_KEY');
$response = BootpayApi::cancelPayment([
'receipt_id' => '[ receipt_id ]',
'cancel_price' => 1000,
'cancel_tax_free' => 0,
'cancel_id' => '[ cancel_id ]',
'cancel_username' => '홍길동',
'cancel_message' => '고객 요청에 의한 취소',
]);
print_r($response);phpimport kr.co.bootpay.pg.Bootpay;
import kr.co.bootpay.pg.model.request.Cancel;
Bootpay bootpay = Bootpay.withClientKey("CLIENT_KEY", "SECRET_KEY");
Cancel cancel = new Cancel();
cancel.receiptId = "[ receipt_id ]";
cancel.cancelPrice = 1000;
cancel.cancelTaxFree = 0;
cancel.cancelId = "[ cancel_id ]";
cancel.cancelUsername = "홍길동";
cancel.cancelMessage = "고객 요청에 의한 취소";
var response = bootpay.receiptCancel(cancel);
System.out.println(response);javabootpay = Bootpay::Api.new(client_key: 'CLIENT_KEY', secret_key: 'SECRET_KEY')
response = bootpay.cancel_payment(
receipt_id: '[ receipt_id ]',
cancel_price: 1000,
cancel_tax_free: 0,
cancel_id: '[ cancel_id ]',
cancel_username: '홍길동',
cancel_message: '고객 요청에 의한 취소'
)
puts responserubyimport "github.com/bootpay/backend-go/v2"
api := bootpay.NewAPIWithClientKey("CLIENT_KEY", "SECRET_KEY", nil, "")
response, err := api.ReceiptCancel(bootpay.CancelData{
ReceiptId: "[ receipt_id ]",
CancelPrice: 1000,
CancelTaxFree: 0,
CancelId: "[ cancel_id ]",
CancelUsername: "홍길동",
CancelMessage: "고객 요청에 의한 취소",
})
fmt.Println(response)gousing Bootpay;
using Bootpay.models;
var bootpay = BootpayApi.WithClientKey("CLIENT_KEY", "SECRET_KEY");
var response = await bootpay.ReceiptCancel(new Cancel
{
receiptId = "[ receipt_id ]",
cancelPrice = 1000,
cancelTaxFree = 0,
cancelId = "[ cancel_id ]",
cancelUsername = "홍길동",
cancelMessage = "고객 요청에 의한 취소"
});
Console.WriteLine(response);csharp응답
취소 요청 결과도 status를 기준으로 처리해요. status: 20이면 전액 취소가 완료된 상태예요.
응답의 price는 원래 결제금액이 아니라 취소분을 뺀 잔액(원 결제금액 − 누적 취소금액)이에요. 그래서 전액 취소가 끝나면 price는 0이 되고, 취소한 금액은 cancelled_price에 누적돼요.
{
"receipt_id": "6244f60c1fc19202e42e8c4e",
"order_id": "1648686604470",
"price": 0,
"cancelled_price": 1000,
"status": 20,
"status_locale": "결제취소완료",
"cancelled_at": "2025-01-16T10:30:00+09:00"
}json{
"receipt_id": "6244f60c1fc19202e42e8c4e",
"order_id": "1648686604470",
"price": 7000,
"cancelled_price": 3000,
"status": 1,
"status_locale": "결제완료",
"last_cancelled_at": "2025-01-16T10:30:00+09:00"
}json{
"error_code": "RC_CANCEL_SERVER_ERROR",
"pg_error_code": "8104",
"message": "취소 가능 기간이 지나 취소할 수 없습니다.",
"payload": {
"ResultCode": "8104",
"ResultMsg": "취소 가능 기간이 지나 취소할 수 없습니다."
}
}json잔액이 남는 부분취소는 결제 상태를 그대로 유지해요. 즉 응답의 status는 1(결제완료) 이고, status: 20은 잔액이 0이 되는 전액 취소에서만 나와요. 부분취소 여부는 status가 아니라 cancelled_price > 0 && price > 0 으로 판정해요.
취소 실패 응답의 payload에는 PG가 내려준 원본 응답이 그대로 담겨요. 필드 이름과 구조는 PG마다 다르므로 고정 스키마를 가정하지 말고, 운영 디버깅용으로 통째로 로깅해요. pg_error_code에는 PG 응답 코드가 들어와요.
응답 필드
취소 성공 응답은 결제 조회 응답과 같은 구조예요. 자주 쓰는 필드는 아래와 같아요.
| 필드 | 타입 | 설명 |
|---|---|---|
receipt_id |
String | Bootpay 영수증 ID |
order_id |
String | 가맹점 주문번호 |
price |
Number | 취소분을 뺀 잔액 (전액 취소 시 0) |
tax_free |
Number | 취소분을 뺀 잔여 면세 금액 |
cancelled_price |
Number | 누적 취소 금액 |
cancelled_tax_free |
Number | 누적 취소 면세 금액 |
order_name |
String | 주문명 |
company_name |
String | 가맹점명 |
currency |
String | 통화 (KRW 등) |
pg |
String | PG명 |
method / method_symbol |
String | 결제수단 한글명 / 영문 심볼 |
method_origin / method_origin_symbol |
String | 간편결제 내부에서 실제 사용된 원 결제수단 |
status |
Number | 결제 상태 (전액취소 20, 부분취소 후 1) |
status_locale |
String | 상태 한글 표기 |
purchased_at |
String | 결제 완료 시각 |
cancelled_at |
String | 전액 취소 시각. 부분취소만 있었던 건에는 담기지 않아요 |
requested_at |
String | 결제 요청 시각 |
receipt_url |
String | 영수증 URL |
sandbox |
Boolean | 샌드박스 결제 여부 |
metadata |
Object | 결제 요청 시 넣은 메타데이터 |
cancel_id |
String | 요청한 취소 고유 ID (API 버전 2.5 이상) |
last_cancelled_at |
String | 마지막 취소 시각 (API 버전 2.5 이상) |
응답은 값이 비어 있는 필드를 제거한 뒤 내려와요. 필드 존재를 전제로 파싱하면 안 돼요.
에러 코드
인증·권한 관련 에러는 에러 코드표를 참고해요.
| 코드 | 메시지 | 대처 방법 |
|---|---|---|
RC_NOT_FOUND (2000) |
영수증 정보를 찾지 못했습니다 | receipt_id가 올바른지 확인해요 |
RC_ALREADY_CANCELLED (2027) |
이미 취소 처리된 결제이다 | 결제 상태를 먼저 조회한다 |
RC_NOT_ABLE_CANCEL (2028) |
결제 완료 상태가 아니라 취소할 수 없다 | 결제 상태를 확인해요 |
RC_CANCEL_PRICE_NOT_ZERO (2051) |
취소금액이 0보다 커야 한다 | cancel_price를 0보다 큰 값으로 설정해요 |
RC_CANCEL_PRICE_OVER (2052) |
요청된 취소금액이 취소 가능 금액보다 큽니다. 남은금액: {잔액}, 취소요청금액: {요청금액} — 실제 금액이 런타임에 채워져요 |
기준은 원 결제금액이 아니라 취소 가능 잔액(price)이에요. 조회 응답의 price를 확인 후 재시도해요 |
RC_CANCEL_TAX_FREE_OVER (2053) |
요청된 면세 취소 금액이 취소 가능한 면세 금액보다 큽니다. 취소가능한 면세금액: {잔여 면세}, 요청된 면세취소금액: {요청 면세} — 실제 금액이 런타임에 채워져요 |
기준은 잔여 면세 금액(tax_free)이에요. cancel_tax_free를 확인해요 |
RC_CANCEL_ID_ALREADY_EXIST (2029) |
동일한 cancel_id로 이미 취소 요청됨 | 다른 cancel_id를 사용해요 |
RC_PM_CANCEL_NOT_SUPPORT (2030) |
PG 결제수단에서 취소 API 미지원 | PG사에 취소 지원 여부를 확인해요 |
RC_PM_P_CANCEL_NOT_SUPPORT (2031) |
PG 결제수단에서 부분취소 미지원 | 전액 취소를 시도하거나 PG사에 문의해요 |
RC_CANCEL_METHOD_NOT_FOUND (2033) |
취소 구현이 되지 않은 PG이다 | PG사에 문의해요 |
RC_CANCEL_SERVER_ERROR (2032) |
결제 취소 오류 | 잠시 후 재시도하거나 부트페이에 문의해요 |
RC_CANCEL_CRITICAL_ERROR (2086) |
결제 취소 중 치명적 오류 발생 | 부트페이 관리자에 문의해요 |
RC_NOT_ISSUED (2039) |
가상계좌 입금 대기 상태가 아닙니다. | 가상계좌 발급취소(입금 전 발급 철회) 전용 에러예요. 입금이 끝난 건은 일반 취소 API로 처리해요 |
RC_ISSUE_CANCEL_ERROR (2066) |
가상계좌 발급 취소 처리 중 오류 (실패 원인이 메시지에 실려요) | 메시지의 원인을 확인하고 재시도하거나 부트페이에 문의해요 |
RC_ISSUE_CANCEL_METHOD_NOT_FOUND (2067) |
가상계좌 발급 취소 API를 지원하지 않는 PG입니다. | 해당 PG는 발급취소를 지원하지 않아요. 입금 기한 만료를 기다리거나 PG사에 문의해요 |
부분 취소는 일부 PG사에서 지원하지 않을 수 있어요. 부분 취소 미지원 PG사에서는 전액 취소만 가능해요.
전체취소는 즉시 처리되어 카드사에서 카드 소유주에게 취소 알림이 바로 발송돼요. 그러나 부분취소 또는 전표 매입 후 취소(후취소)의 경우, 영업일 기준 3~5일 후 취소 정보가 카드 소유주에게 전달돼요. 이 기간 동안 고객이 취소 여부를 인지하기 어려울 수 있으므로, 취소 처리 후 고객에게 별도로 안내하는 것을 권장해요.
결제수단별 취소 정책
| 결제수단 | 부분취소 | 취소 가능 기간 | 비고 |
|---|---|---|---|
| 카드 | O | 180일 이내 | 당일 결제: 즉시 취소 / 전표 매입 후: 영업일 3~5일 소요 |
| 계좌이체 | O | 180일 이내 | 즉시 처리 |
| 가상계좌 | 일부 PG만 | 1년 이내 | CMS 이체 특약 필요, 취소 시 약 300원 수수료 발생 |
| 휴대폰 | X | 당월만 | 이전 달 결제는 취소 불가 |
| 네이버페이 | O | 3년(1,095일) 이내 | |
| 카카오페이 | O | 카드 1년 / 머니 5년 이내 | |
| 페이코 | O | 카드 180일 / 계좌 90일 이내 |
휴대폰 소액결제는 통신사 정산 주기 때문에 결제한 달(당월) 안에서만 취소할 수 있고, 부분취소도 지원하지 않아요. 달이 바뀐 뒤에는 PG를 통한 취소가 막히므로, 이런 건은 가맹점이 고객에게 직접 환불(계좌 송금 등)해야 해요. 같은 이유로, 표의 취소 가능 기간이 지났거나 가상계좌처럼 별도 특약이 없는 건도 PG 자동 취소가 불가능하니 직접 환불 흐름을 마련해 둬요.
가상계좌 취소 시 refund 객체에 환불 계좌 정보(bank_code, bank_account, bank_username)를 반드시 포함해야 해요. 환불 계좌 전달이 구현된 PG는 이니시스, 웰컴페이먼츠, 나이스페이먼츠, KICC(이지페이), 키움페이, 모빌리언스예요. KCP·다날은 구현돼 있지 않아요.
결제수단별 취소 가능 기간이 경과한 건은 PG사를 통한 자동 취소가 불가능해요. 가맹점에서 직접 고객에게 환불 처리해야 해요.
