NestPay PG 연동 가이드

QR·앱 결제 게이트웨이 — 매장(쇼핑몰) 서버 개발자용 연동 문서

외부 공개 문서 · 내부 API(Swagger)와 별개
목차
1. 개요·흐름 2. 온보딩(신청→라이브) 3. 인증(HMAC·화이트IP) 4. API 엔드포인트 5. 웹훅 6. 시나리오별 처리 7. 상태·오류코드 8. AI 로 연동하기

1. 개요 · 결제 흐름

NestPay PG 는 QR/앱 기반 결제 게이트웨이입니다(위챗페이 방식). 매장 서버가 결제 대상(상품·금액·부가세)을 등록하면 NestPay 가 결제용 QR·딥링크를 돌려주고, 사용자가 NestPay 앱으로 결제하면 웹훅조회 API로 결과를 알려 줍니다. 유효시간이 지나면 결제 실패로 처리됩니다.

1
매장 서버 → POST /pg/payments (주문 생성) → paymentId·qrData·deeplink·만료시각 수신
2
매장 → QR 표시(PC 웹) 또는 딥링크로 앱 열기(모바일 웹)
3
사용자 → NestPay 앱으로 스캔·결제
4
NestPay → 매장 서버로 웹훅(PAYMENT_COMPLETED) 발송 + 매장은 GET /pg/payments/{id}로도 확인
5
미결제로 유효시간 경과 → 웹훅(PAYMENT_EXPIRED) + 조회 시 EXPIRED
기준 주소(Base URL): https://npwp-pg.nestpay.co.kr ← 지금 보고 있는 서버 기준으로 자동 표시됩니다 · 모든 경로는 /pg 로 시작합니다(예: https://npwp-pg.nestpay.co.kr/pg/payments). 운영은 npwp-pg.nestpay.co.kr, 테스트는 npwp-tpg.nestpay.co.kr 입니다. PG 연동은 이 전용 호스트만 사용합니다(내부 API 와 분리). 유효시간은 NestPay 관리자 설정값을 따릅니다(기본 5분).

2. 온보딩 — 신청 → 승인 → 테스트 → 라이브

연동은 4단계로 진행되며, 샌드박스에서 4종 테스트를 모두 통과해야 라이브로 전환됩니다.

단계내용
① 신청매장앱에서 PG 연동 신청 → client_id·api_key(1회 표시) 발급, 웹훅 주소 등록(서명키 1회 표시), 서버 IP 화이트리스트 신청
② 승인NestPay 관리자가 신청·화이트IP 승인 → 샌드박스 모드로 API 사용 가능
③ 테스트샌드박스에서 아래 4종을 통과(자동 기록). 통과 조건이 각각 다릅니다 — 바로 아래 표 참고
④ 라이브4종 테스트 통과 확인 후 관리자가 라이브 전환 → 실제 결제 시작

테스트 4종 — 통과 조건

항목통과 조건
① 결제 생성 POST /pg/payments 로 주문을 만들면 통과합니다.
② 결제 조회 GET /pg/payments/{id}결제가 끝난 주문(PAID 또는 CANCELED)을 읽어야 통과합니다. 대기(PENDING) 상태만 조회하면 통과하지 않습니다 — 결제 결과를 실제로 읽는지 확인하기 위해서입니다.
③ 웹훅 수신 등록한 콜백 주소로 PAYMENT_COMPLETED 알림을 받고 2xx 로 답해야 통과합니다. 만료(PAYMENT_EXPIRED) 알림만 받은 경우는 통과하지 않습니다.
④ 서명 검증 매장앱의 [서명 검증 시험 실행] 을 누르면, NestPay 가 일부러 서명을 틀리게 만든 시험 알림 (event: "SIGNATURE_TEST")을 콜백 주소로 한 번 보냅니다. 쇼핑몰이 4xx 로 거절하면 통과, 2xx 로 답하면 불통과입니다.
④ 가 왜 필요한가요? 서명을 확인하지 않으면 누구나 쇼핑몰 콜백 주소로 가짜 "결제 완료" 알림을 보낼 수 있습니다. 돈은 들어오지 않았는데 물건이 나갑니다. 웹훅을 받을 때 반드시 X-Nestpay-Signature 를 서명키로 검증하고, 다르면 4xx 로 거절하세요.
모드 구분: 인증 응답·조회 결과의 sandbox 값으로 현재 모드를 확인하세요. 샌드박스와 라이브는 같은 주소·같은 열쇠를 쓰며, 승인된 모드에 따라 서버가 구분합니다.
샌드박스도 실제 결제입니다: 모의 결제가 아닙니다. 손님(회원)이 QR 을 찍으면 실제 잔액이 빠지고 장부에 남습니다. 적은 금액으로 시험하고, 끝나면 취소하세요.
시도 내역이 남습니다: 보낸 값·받은 값·막힌 이유(서명 불일치·IP 미허용 등)가 모두 기록됩니다. 매장앱 → PG 연동 관리 → 내 연동 호출 기록에서 직접 확인할 수 있습니다. 연동이 막히면 먼저 여기를 보세요.

3. 인증 — HMAC 서명 + 화이트IP

모든 /pg 요청에는 아래 4개 헤더가 필요합니다. 승인된 IP 에서만 호출할 수 있습니다.

헤더설명
X-Client-Id발급받은 매장코드
X-Api-Key발급받은 비밀키
X-Timestamp요청 시각(Unix epoch ). 서버 시각과 오차가 크면 거부. 상태를 바꾸는 요청(POST 등)의 서명은 1회용이라, 같은 서명을 다시 보내면 거부됩니다(재전송·중복 처리 방지). 재시도는 시각을 갱신해 새 서명으로 보내세요(조회 GET 은 제외).
X-Signature아래 규칙의 HMAC-SHA256 서명(소문자 16진수)

서명 만드는 법

서명 대상 문자열(canonical)은 다음 4줄을 \n(줄바꿈)으로 이어 붙입니다. 경로에는 쿼리스트링(?status=…&page=…)까지 포함합니다(GET 의 쿼리도 위·변조 방지를 위해 서명 대상). 본문이 없으면 빈 문자열입니다.

canonical = HTTP메서드 + "\n" + 경로(쿼리 포함) + "\n" + X-Timestamp + "\n" + 본문(raw)
signature = HMAC_SHA256(api_key, canonical) 를 소문자 16진수로
# 예: GET 이면 경로 자리에 "/pg/payments?status=PAID&page=1&limit=20" 처럼 보낸 그대로(쿼리 포함) 씁니다.
서명은 보낸 본문 바이트 그대로에 대해 계산해야 합니다(공백·키 순서까지 동일). 서버는 받은 본문 원문으로 다시 서명해 대조합니다.
# 예: 주문 생성 요청 서명 (Python)
import hmac, hashlib, time, json, requests
CLIENT_ID = "mc_xxxxxxxxxxxx"; API_KEY = "발급받은_비밀키"
body = json.dumps({"orderNo":"SHOP-1001","amount":11000,"vatIncluded":True,
                   "items":[{"name":"아메리카노","qty":2,"unitPrice":4000},
                            {"name":"쿠키","qty":1,"unitPrice":3000}],   # 수량×단가 합 = amount
                   "returnInfo":"SHOP-ORDER-1001"})                      # 임의 정보 — 그대로 echo
ts = str(int(time.time()))
canonical = f"POST\n/pg/payments\n{ts}\n{body}"
sig = hmac.new(API_KEY.encode(), canonical.encode(), hashlib.sha256).hexdigest()
r = requests.post("https://npwp-pg.nestpay.co.kr/pg/payments", data=body, headers={
    "Content-Type": "application/json",
    "X-Client-Id": CLIENT_ID, "X-Api-Key": API_KEY, "X-Timestamp": ts, "X-Signature": sig,
})

4. API 엔드포인트

공통 응답 포장: { "success": true, "data": { ... }, "error": null }. 실패 시 success:false, error에 사유.

POST /pg/payments — 결제 주문 생성

요청 필드필수설명
orderNoY매장 주문번호(매장별 유일). 같은 값 재요청 시 기존 주문을 그대로 반환(멱등)
itemName조건부결제 대상 이름(단일 상품). items를 보내면 생략 가능 — 생략 시 "첫 상품명 외 N건"으로 자동 생성
amountY청구 총액(원, 0 초과)
vatIncludedYtrue=금액에 부가세 포함(서버가 공급가/부가세 10% 분리) · false=면세(부가세 0)
itemsN다품목 목록(최대 100개) — 상품이 여러 개이거나 상품별 수량이 다른 묶음 결제. 각 원소는 { name(상품명·200자), qty(수량 1~9999), unitPrice(단가·원), taxFree(선택) }. 모든 품목의 수량×단가 합이 amount와 정확히 일치해야 하며, 다르면 400 거절(금액 무결성).
taxFree: true=이 품목 면세 · false=과세(품목 금액에 부가세 포함 → 분리) · 생략 시 주문 전체 vatIncluded를 따름. 과세·면세 혼합 장바구니(예: 면세 1만원 + 과세 2만원)는 품목별로 공급가/부가세를 나눈 뒤 합산합니다(주문의 supplyAmount/vatAmount = 품목 합). 품목 목록은 과세구분·공급가·부가세까지 주문 시점 스냅샷으로 보존되어 조회 때 그대로 돌려받습니다
returnInfoN쇼핑몰의 임의 정보(자체 주문코드 등, 500자 이하). NestPay는 내용을 해석하지 않고 생성 응답·결제 조회·모든 웹훅에 그대로 되돌려줍니다(echo) — 쇼핑몰 주문과의 대사(매칭)용
// 응답 data
{
  "paymentId": 1024, "orderNo": "SHOP-1001", "status": "PENDING",
  "amount": 11000, "supplyAmount": 10000, "vatAmount": 1000,
  "itemName": "아메리카노 외 1건",        // items 만 보냈으면 자동 생성된 대표 이름
  "returnInfo": "SHOP-ORDER-1001",        // 보낸 리턴정보를 그대로 echo
  "qrData": "NPQR1.xxxx",                 // QR 로 그려 사용자에게 표시
  "deeplink": "nestpay://pay?token=NPQR1.xxxx", // 모바일 웹은 이 링크로 앱 열기
  "expiresAt": "2026-07-26T12:34:56", "expiresInSeconds": 300, "sandbox": true
}

GET /pg/payments/{paymentId} — 결제 조회

웹훅을 못 받았거나 확인이 필요할 때 언제든 폴링할 수 있습니다. status로 결과를 판단하세요.

// 응답 data
{ "paymentId":1024, "orderNo":"SHOP-1001", "status":"PAID", "amount":11000,
  "itemName":"아메리카노 외 1건",
  "items":[ {"name":"아메리카노","qty":2,"unitPrice":4000,"amount":8000,   // 주문 시점 품목 스냅샷
             "taxFree":false,"supplyAmount":7273,"vatAmount":727},          // 품목별 과세구분·공급가·부가세
            {"name":"쿠키","qty":1,"unitPrice":3000,"amount":3000,
             "taxFree":true,"supplyAmount":3000,"vatAmount":0} ],           // (다품목이 아니면 null)
  "returnInfo":"SHOP-ORDER-1001",                                          // 보낸 값 그대로 echo
  "supplyAmount":10273, "vatAmount":727,                                   // 주문 전체 공급가·부가세
  "txnId":55231,                                                           // 결제 확정 시 생기는 거래번호(미결제면 null)
  "paidAt":"2026-07-26T12:31:10",                                          // 결제 완료 시각(미결제면 null)
  "canceledAt":null,                                                       // 취소·환불 시각(없으면 null)
  "sandbox":true }
// 모든 시각은 KST(한국시간) 기준입니다.

POST /pg/payments/{paymentId}/cancel — 취소 / 환불

하나의 엔드포인트가 상태에 따라 동작합니다. 부분취소·부분환불은 제공하지 않습니다(전액만).

주문 상태동작
PENDING(미결제)취소 — QR 소각(이후 결제 시도 불가) + 상태 CANCELED + 취소 웹훅
PAID(결제완료)전액 환불 — 사용자에게 전액 환불(원장 역분개) + 상태 CANCELED + 환불 웹훅
EXPIRED취소 불가(이미 만료). 별도 조치 불필요
CANCELED멱등 — 그대로 CANCELED 반환
{ "paymentId": 22, "status": "CANCELED", "refunded": false }
// 결제완료 건을 환불한 경우 → { "status":"CANCELED", "refunded":true, "refundAmount":11000 }
// refunded=false → 미결제 주문을 취소한 것 · true → 결제완료 건을 전액 환불한 것
★ 주의 — status 라는 이름이 두 곳에서 다른 값을 가집니다.
환불해도 주문 상태는 CANCELED 입니다(조회 API·취소 응답 모두). 별도의 REFUNDED 주문 상태는 없습니다.
반면 웹훅 본문의 status"REFUNDED" 로 옵니다(그 알림이 환불이었음을 알리는 라벨입니다).
웹훅의 status 를 그대로 주문 상태로 저장하면, 나중에 조회 API 와 값이 어긋납니다. 주문 상태는 조회 API 의 status 를 기준으로 삼고, 환불 여부는 event(PAYMENT_REFUNDED)로 판단하세요.

GET /pg/payments?status=&page=&limit= — 결제 내역

매장 본인 결제 내역만 돌려줍니다(다른 매장 건은 보이지 않습니다). status 는 생략하면 전체입니다.

{ "total": 14, "page": 1, "limit": 1,
  "items": [
    { "paymentId": 22, "orderNo": "SAMPLE-1001", "status": "PENDING",
      "amount": 11000, "supplyAmount": 11000, "vatAmount": 0,
      "itemName": "아메리카노 외 1건", "returnInfo": "SHOP-ORDER-1001",
      "createdAt": "2026-08-12T04:30:04",   // 주문 만든 시각
      "paidAt": null,                        // 결제 완료 시각(미결제면 null)
      "canceledAt": null,                    // 취소·환불 시각(없으면 null)
      "txnId": null,                         // 결제 확정 시 생기는 거래번호
      "sandbox": false }
  ] }
// 목록 항목에는 items(품목 목록)가 들어가지 않습니다 — 품목은 단건 조회로 확인하세요.
// 모든 시각은 KST(한국시간) 기준입니다.

GET /pg/balance — 정산 예정 잔액

{ "settlementBalance": 452000, "paidCount": 61, "paidSum": 690000, "refundedCount": 3 }

POST /pg/ping — 연동(HMAC) 연결 시험

발급 정보·서명·화이트IP 설정이 올바른지 확인하는 무해한 시험 호출입니다(상태 변경 없음). 연동을 처음 붙일 때 이것부터 200 이 되게 만드세요 — 서명·IP 문제를 먼저 걸러 냅니다.

{ "status": "OK", "merchantId": 1, "mode": "LIVE" }
// mode : "SANDBOX"(시험) 또는 "LIVE"(실거래). 지금 어느 모드인지 여기서 확인합니다.
// 본문은 비워도 됩니다(빈 객체 {} 를 보내세요 — 서명 계산 시 본문 원문과 일치해야 함).

POST /pg/settlements — 정산 요청

정산 예정 잔액을 등록된 정산 계좌로 출금 신청합니다. idempotencyKey 필수(재전송·중복 호출 시 이중 정산 방지).

요청 필드필수설명
amountY정산(출금) 금액(원, 0 초과 · 정산 예정 잔액 이하)
idempotencyKeyY같은 정산 신청의 재시도를 구분하는 고유키 — 같은 키 재요청은 중복 처리되지 않음
{ "txnId": 80, "amount": 1000, "feeAmount": 0, "netAmount": 1000,
  "balanceAfter": 10065, "scheduledAt": "2026-08-12T04:30:05" }
// netAmount   : 수수료를 뺀 실지급액   · balanceAfter : 신청 후 남는 정산 예정 잔액
// scheduledAt : 실제 지급 예정 시각(정산 정책에 따라 정해집니다)

GET /pg/settlements — 정산 내역

요청·처리중·완료 상태의 정산 내역을 최근순으로 돌려줍니다.

[ { "settlementId": 80, "amount": 1000, "feeAmount": 0,
    "status": "PENDING", "statusLabel": "처리중",
    "requestedAt": "2026-08-12T04:30:05", "completedAt": null } ]
statusstatusLabel
CONFIRMED완료계좌로 실제 입금됨(completedAt 채워짐)
FAILED실패이체 실패 — 금액이 정산 예정 잔액으로 복원됩니다
CANCELED취소신청이 취소됨
그 외처리중접수·이체 진행 중(completedAt 은 null)

5. 웹훅(콜백)

결제 결과가 확정되면 등록한 주소로 POST합니다. 본문은 JSON, 헤더로 이벤트와 서명이 옵니다.

헤더설명
X-Nestpay-Event이벤트 종류(아래)
X-Nestpay-SignatureHMAC_SHA256(webhook_secret, 본문) 소문자 16진수 — 반드시 검증
이벤트의미
PAYMENT_COMPLETED결제 완료(status=PAID)
PAYMENT_EXPIRED유효시간 경과로 결제 실패(status=EXPIRED)
PAYMENT_CANCELED미결제 주문 취소(status=CANCELED)
PAYMENT_REFUNDED결제완료건 전액 환불 — 웹훅 본문의 status 는 "REFUNDED", 주문 상태는 CANCELED
// 웹훅 본문 예 — returnInfo(주문 생성 때 보낸 임의 정보)가 그대로 되돌아옵니다(대사용)
{ "event":"PAYMENT_COMPLETED", "paymentId":1024, "orderNo":"SHOP-1001", "amount":11000,
  "status":"PAID", "txnId":55231, "returnInfo":"SHOP-ORDER-1001" }

// 서명 검증(Python)
import hmac, hashlib
def valid(body_bytes, header_sig, webhook_secret):
    calc = hmac.new(webhook_secret.encode(), body_bytes, hashlib.sha256).hexdigest()
    return hmac.compare_digest(calc, header_sig)
재시도: 2xx 응답이 아니면 지수 백오프로 최대 10회 재시도합니다(24시간 내). 그래도 실패하면 발송을 멈춥니다 → 이 경우 매장은 조회 API 로 결과를 확인해야 합니다. 응답은 빠르게 200을 돌려주고 처리는 비동기로 하세요.
멱등 처리 필수: 같은 이벤트가 두 번 이상 올 수 있습니다(재시도·네트워크). paymentId+event 기준으로 이미 처리한 건이면 무시하도록 구현하세요.

6. 시나리오별 처리

상황NestPay 동작매장(쇼핑몰) 처리
정상 결제결제요청 → 결제 → PAYMENT_COMPLETED 웹훅/조회 PAID주문 결제완료 처리
결제 후, 이미 쇼핑몰 주문이 취소됨환불요청(/cancel) → PAYMENT_REFUNDED환불요청 호출 후 환불 확정 처리
미결제로 유효시간 경과자동 만료 → PAYMENT_EXPIRED, 조회 EXPIRED주문 미결제 처리(장바구니 복원 등)
미결제 상태에서 쇼핑몰이 주문 취소취소요청(/cancel) → QR 소각 → PAYMENT_CANCELED취소 확정 처리
취소 후 사용자가 뒤늦게 결제 시도QR 이 소각돼 결제 거부(에러)추가 처리 불필요(이미 취소됨)
웹훅 유실(재시도 소진)발송 중단조회 API 폴링으로 최종 상태 확정(웹훅과 조회는 상호 보완)
웹훅 중복 수신재시도로 같은 이벤트 재발송 가능멱등 처리(paymentId+event 기준 1회만)
주문 생성 재요청(동일 orderNo)기존 주문 그대로 반환(중복 생성 방지)동일 결제창 재사용
만료와 결제가 거의 동시QR·주문 만료 시각이 동일 → 만료 후 결제는 거부. 결제가 먼저면 PAID 고정조회 API 최종 상태를 신뢰

7. 상태 · 오류

주문 상태(status): PENDING(대기) · PAID(완료) · EXPIRED(만료) · CANCELED(취소/환불).

HTTP의미 / 대응
200정상. 단 successfalseerror 확인
400필수값 누락·형식 오류·취소 불가 상태 등 요청 문제
401인증 실패 전반 — 서명 불일치·헤더 누락·시각 오차·재사용(replay)·미승인 화이트IP(HMAC·IP 재확인)
404주문 없음 (본인 매장의 주문이 아니면 조회되지 않아 404)

참고: 인증·IP 실패는 모두 401로 통일 반환됩니다(별도 403 없음).

8. AI 로 연동하기

연동 코드를 AI 에게 맡기실 수 있습니다. 아래 상자를 통째로 복사해 쓰시는 AI(Claude·ChatGPT·Copilot 등)에 붙여 넣고, 맨 아래 <내 상황> 만 채우세요. 여기 적힌 내용은 실제로 동작하는 서버에서 뽑아낸 것이라 AI 가 없는 규칙을 지어내지 않습니다.

붙여 넣기 전에 꼭 확인하세요. API_KEY·WEBHOOK_SECRET절대 프롬프트에 적지 마세요. 열쇠는 서버 환경변수로만 넣고, AI 에게는 "환경변수에서 읽어 쓰라"고 시키면 됩니다. 아래 프롬프트도 그렇게 적혀 있습니다.
아래 내용을 그대로 복사해 AI 에 붙여 넣으세요
당신은 쇼핑몰 서버에 NestPay PG 결제를 연동하는 개발자입니다.
아래 규격만 사용하고, 여기 없는 필드·엔드포인트·규칙을 임의로 만들지 마세요.
불확실하면 "규격에 없음"이라고 답하고 멈추세요.

# 1. 기본
- 기준 주소(Base URL): https://npwp-pg.nestpay.co.kr
- 모든 경로는 /pg 로 시작합니다.
- 요청·응답 본문은 JSON(UTF-8). 모든 시각은 KST(한국시간) 기준입니다.
- 공통 응답 형태: { "success": true|false, "data": {...}, "error": "실패 사유", "meta": null }
  → success 가 false 면 error 를 읽어 처리하세요. HTTP 200 이어도 success=false 일 수 있습니다.

# 2. 인증 (요청마다 4개 헤더)
- X-Client-Id  : 발급받은 클라이언트 ID
- X-Api-Key    : 발급받은 API 열쇠
- X-Timestamp  : 현재 시각(Unix epoch 초, 문자열)
- X-Signature  : 아래 서명값(소문자 16진수)

서명 만드는 법:
  canonical = METHOD + "\n" + 경로(쿼리스트링 포함) + "\n" + X-Timestamp + "\n" + 요청본문원문
  X-Signature = HMAC_SHA256(API_KEY, canonical) 를 소문자 16진수로

규칙:
- 본문이 없으면(GET 등) 본문 자리는 빈 문자열입니다.
- 본문은 해시하지 않고 "보낸 원문 그대로" 씁니다. 직렬화한 문자열을 그대로 전송해야 하며,
  서명 계산에 쓴 문자열과 실제 전송 본문이 한 글자라도 다르면 401 이 납니다.
- 경로는 쿼리스트링까지 포함합니다. 예: /pg/payments?status=PAID&page=1
- X-Timestamp 는 서버 시각과 300초(5분) 이상 차이 나면 거부됩니다. 서버 시각을 NTP 로 맞추세요.
- API_KEY 는 환경변수에서 읽어 쓰고, 코드·로그·이 프롬프트에 절대 남기지 마세요.
- 발급받은 서버 IP 만 허용됩니다(화이트리스트). 미승인 IP 는 401 입니다.

# 3. 엔드포인트 (이게 전부입니다 — 8개)

POST /pg/ping
  연결·서명·IP 설정 점검용. 본문은 빈 객체 {} 를 보냅니다.
  응답 data: { "status":"OK", "merchantId":1, "mode":"SANDBOX"|"LIVE" }
  → 연동을 처음 붙일 때 이것부터 200 이 되게 만드세요.

POST /pg/payments   (결제 주문 생성)
  요청: {
    "orderNo": "SHOP-1001",          // 필수. 쇼핑몰 주문번호
    "amount": 11000,                 // 결제 총액(원). items 를 보내면 생략 가능
    "itemName": "아메리카노 외 1건",  // items 를 보내면 생략 가능(자동 생성)
    "vatIncluded": true,             // 선택. 금액에 부가세가 포함되어 있는지
    "items": [                       // 선택. 다품목(최대 100개)
      { "name":"아메리카노", "qty":2, "unitPrice":4000, "taxFree":false }
    ],
    "returnInfo": "SHOP-ORDER-1001"  // 선택. 문자열(500자 이하). 그대로 되돌려 받습니다
  }
  응답 data: {
    "paymentId":22, "orderNo":"SHOP-1001", "status":"PENDING",
    "amount":11000, "supplyAmount":10273, "vatAmount":727, "itemName":"아메리카노 외 1건",
    "qrData":"NPQR1.xxxxx",                 // QR 로 그릴 문자열(PC 웹)
    "deeplink":"nestpay://pay?token=NPQR1.xxxxx",  // 앱 열기(모바일 웹)
    "expiresAt":"2026-08-12T04:32:37", "expiresInSeconds":299,
    "returnInfo":"SHOP-ORDER-1001", "sandbox":false
  }
  주의: 같은 orderNo 로 다시 요청하면 새로 만들지 않고 기존 주문을 그대로 돌려줍니다(중복 생성 방지).

GET /pg/payments/{paymentId}   (결제 단건 조회)
  응답 data: {
    "paymentId":22, "orderNo":"SHOP-1001", "status":"PENDING"|"PAID"|"EXPIRED"|"CANCELED",
    "amount":11000, "supplyAmount":10273, "vatAmount":727, "itemName":"...",
    "items":[ {"name","qty","unitPrice","amount","taxFree","supplyAmount","vatAmount"} ], // 없으면 null
    "returnInfo":"SHOP-ORDER-1001",
    "txnId":55231,                  // 결제 확정 시 생기는 거래번호(미결제면 null)
    "paidAt":"2026-07-26T12:31:10", // 미결제면 null
    "canceledAt":null,              // 취소·환불 시각(없으면 null)
    "sandbox":false
  }

GET /pg/payments?status=&page=&limit=   (결제 내역)
  본인 매장 건만 조회됩니다. status 생략 시 전체.
  응답 data: { "total":14, "page":1, "limit":20, "items":[ 단건조회와 같은 필드 + "createdAt" ] }
  주의: 목록 항목에는 items(품목 목록)가 없습니다. 품목은 단건 조회로 확인하세요.

POST /pg/payments/{paymentId}/cancel   (취소 / 환불)
  본문은 빈 객체 {}. 주문 상태에 따라 동작이 갈립니다.
    PENDING  → 취소(QR 소각). 응답 { "paymentId":22, "status":"CANCELED", "refunded":false }
    PAID     → 전액 환불.     응답 { "paymentId":22, "status":"CANCELED", "refunded":true, "refundAmount":11000 }
    CANCELED → 멱등. 그대로 CANCELED 를 돌려줍니다(다시 불러도 안전)
    EXPIRED  → 400 오류(만료된 주문은 취소 불가)
  부분취소·부분환불은 없습니다(전액만).

GET /pg/balance   (정산 예정 잔액)
  응답 data: { "settlementBalance":452000, "paidCount":61, "paidSum":690000, "refundedCount":3 }

POST /pg/settlements   (정산 요청)
  요청: { "amount":1000, "idempotencyKey":"IDEM-20260812-01" }   // idempotencyKey 필수
  응답 data: { "txnId":80, "amount":1000, "feeAmount":0, "netAmount":1000,
               "balanceAfter":10065, "scheduledAt":"2026-08-12T04:30:05" }
  같은 idempotencyKey 로 재요청하면 중복 처리되지 않습니다(재전송 안전).

GET /pg/settlements   (정산 내역)
  응답 data: [ { "settlementId":80, "amount":1000, "feeAmount":0,
                 "status":"CONFIRMED"|"FAILED"|"CANCELED"|그외,
                 "statusLabel":"완료"|"실패"|"취소"|"처리중",
                 "requestedAt":"...", "completedAt":null } ]

# 4. 웹훅(콜백) 받기
NestPay 가 등록된 콜백 주소로 POST 합니다.
- 헤더 X-Nestpay-Event     : 이벤트 종류
- 헤더 X-Nestpay-Signature : HMAC_SHA256(WEBHOOK_SECRET, 요청본문원문) 소문자 16진수

이벤트 종류:
  PAYMENT_COMPLETED  결제 완료
  PAYMENT_EXPIRED    유효시간 경과로 결제 실패
  PAYMENT_CANCELED   미결제 주문 취소
  PAYMENT_REFUNDED   결제완료 건 전액 환불
  SIGNATURE_TEST     서명 검증 시험(아래 참고)

본문 예:
  { "event":"PAYMENT_COMPLETED", "paymentId":22, "orderNo":"SHOP-1001",
    "amount":11000, "status":"PAID", "returnInfo":"SHOP-ORDER-1001" }

반드시 지킬 것:
1) 서명 검증을 먼저 하세요. 본문을 파싱하기 전에, 받은 원문 바이트 그대로 HMAC 을 계산해
   X-Nestpay-Signature 와 비교합니다. 다르면 4xx 로 거절하고 아무 처리도 하지 마세요.
   (검증하지 않으면 누구나 가짜 "결제 완료"를 보낼 수 있습니다 — 돈은 안 들어오고 물건이 나갑니다)
2) 비교는 타이밍 공격에 안전한 방식(constant-time)으로 하세요.
3) 멱등 처리하세요. 같은 (paymentId, event) 알림이 재시도로 여러 번 올 수 있습니다. 1회만 반영하세요.
4) 검증에 성공하고 처리했으면 2xx 로 답하세요. 2xx 가 아니면 NestPay 가 재시도합니다.
5) ★ 웹훅 본문의 status 를 주문 상태로 그대로 저장하지 마세요.
   환불 알림의 status 는 "REFUNDED" 이지만, 조회 API 의 주문 상태는 "CANCELED" 입니다.
   주문 상태는 조회 API 의 status 를 기준으로 삼고, 환불 여부는 event 로 판단하세요.
6) 웹훅이 유실될 수 있습니다. 조회 API 폴링을 함께 두어 최종 상태를 확정하세요.

# 5. 결제 흐름
1) 쇼핑몰 서버가 POST /pg/payments 로 주문 생성 → paymentId·qrData·deeplink·expiresAt 수신
2) PC 웹이면 qrData 로 QR 을 그려 보여 주고, 모바일 웹이면 deeplink 로 앱을 엽니다
3) 사용자가 NestPay 앱으로 결제
4) 웹훅(PAYMENT_COMPLETED) 수신 + GET /pg/payments/{paymentId} 로도 확인
5) 유효시간(expiresAt)이 지나면 자동 만료 → 웹훅(PAYMENT_EXPIRED), 조회 시 EXPIRED

# 6. 오류 처리
  200 정상. 단 success=false 면 error 를 확인
  400 필수값 누락·형식 오류·취소 불가 상태
  401 인증 실패 전반 — 서명 불일치·헤더 누락·시각 오차·재사용·미승인 IP
  404 주문 없음(본인 매장 주문이 아니면 404)
  ※ 인증·IP 실패는 모두 401 로 옵니다(403 은 쓰지 않습니다).

# 7. 샌드박스 / 라이브
- 주소와 열쇠가 같습니다. 승인된 모드에 따라 서버가 구분합니다.
- 지금 어느 모드인지는 /pg/ping 의 mode, 또는 조회 응답의 sandbox 로 확인하세요.
- ★ 샌드박스도 실제 결제입니다(모의 결제 아님). 손님 잔액이 실제로 빠집니다.
  적은 금액으로 시험하고, 끝나면 취소하세요.

# 8. 라이브 전환에 필요한 시험 4종
  ① 결제 생성 : POST /pg/payments 성공
  ② 결제 조회 : GET /pg/payments/{id} 로 PAID 또는 CANCELED 상태를 읽기
                (PENDING 만 조회하면 통과되지 않습니다)
  ③ 웹훅 수신 : PAYMENT_COMPLETED 를 받고 2xx 로 응답
  ④ 서명 검증 : NestPay 가 일부러 서명을 틀리게 만든 SIGNATURE_TEST 알림을 보냅니다.
                4xx 로 거절해야 통과, 2xx 로 답하면 불통과입니다.
  → ④ 때문에라도 서명 검증은 반드시 구현해야 합니다.

# 9. 나에게 만들어 줄 것
- 위 규격대로 동작하는 연동 코드
- 서명 생성 함수와 웹훅 서명 검증 함수(테스트 포함)
- 웹훅 멱등 처리와 조회 폴링을 함께 둔 구조
- API_KEY·WEBHOOK_SECRET 은 환경변수에서 읽고, 로그에 남기지 않을 것

<내 상황>
- 사용 언어/프레임워크 :
- 쇼핑몰 주문 저장 방식 :
- 웹훅 받을 주소 :
© NestPay · (주)페이네스트 — PG 연동 개발자 문서 · 문의는 매장 담당자를 통해 주세요.