WAVPAY/ 연동 API

개요

키오스크 주문앱이 결제(VCAT 단말 승인)를 마치면 주문 전체를 플랫폼으로 보냅니다. 플랫폼은 금액을 다시 계산해 검증하고, 같은 주문은 한 번만 저장하며, 가맹점 서버로 웹훅을 보냅니다. 카드 승인·취소 자체는 단말에서 일어나고 플랫폼은 그 결과를 기록·통보합니다.

  • Base URL: https://<host>/api/v1
  • 본문은 JSON (UTF-8), 금액은 원 단위 정수, 시각은 ISO 8601
  • 주문 식별자 receiptId(rcpt_…)는 플랫폼이 발급, externalId는 앱의 주문 id

인증

콘솔 > 개발자에서 발급한 clientKey / secretKey를 HTTP Basic 으로 보냅니다. secret 은 발급 때 한 번만 보이며 서버(또는 단말 보안 저장소)에만 둡니다. 연동키마다 IP 허용목록과 만료일을 둘 수 있습니다.

Authorization: Basic base64(clientKey + ":" + secretKey)

연결 확인: GET /api/v1/ping?deviceId=dev_… → { ok, merchant, device, deviceFound }

주문 전송 — POST /orders

주문이 만들어지거나 바뀔 때(취소 등)마다 주문 객체 전체를 보냅니다. 같은 (deviceId, order.id) 는 몇 번 보내도 한 건이고, 결제완료 주문이 status: "canceled" 로 다시 오면 취소를 반영합니다. 실패하면 그대로 다시 보내면 됩니다(멱등).

curl -X POST https://<host>/api/v1/orders \
  -u "ck_demo_client_key:sk_demo_secret_key_change_me" \
  -H "content-type: application/json" \
  -d @order.json
{
  "deviceId": "dev_demo_gn1",
  "order": {
    "id": "1762671234567",
    "orderNo": 42,
    "createdAt": "2026-11-09T08:30:12.000Z",
    "dineOption": "here",
    "lines": [
      { "menuId": "AME", "name": "아메리카노", "unitPrice": 4500, "quantity": 2, "taxFree": false }
    ],
    "amounts": { "total": 9000, "vat": 818, "taxFree": 0 },
    "payMethod": "card",
    "installment": 0,
    "status": "paid",
    "driver": "plus",
    "approval": {
      "approvalNo": "30012345",
      "approvalDateTime": "261109173012",
      "issuerName": "신한카드",
      "acquirerName": "신한카드",
      "cardBin": "45184200",
      "cardKind": "신용",
      "merchantNo": "00123456789",
      "catId": "2393300001",
      "tranSerial": "123456789012",
      "mgmtNo": "0000000001",
      "message": "정상승인"
    }
  }
}
  • amounts 는 서버가 lines 로 다시 계산해 비교합니다. 다르면 AMOUNT_MISMATCH. (과세분 부가세 = 과세분 − round(과세분 / 1.1))
  • 카드·현금영수증은 approval 필수, 취소 상태면 cancel 필수
  • 현금영수증 식별번호·카드번호는 마스킹해서만 저장합니다.
  • 같은 id 로 금액·결제수단·승인번호가 다른 주문이 오면 ORDER_CONFLICT
HTTP/1.1 201 Created
{
  "result": "created",          // created | canceled | duplicate
  "order": {
    "receiptId": "rcpt_k3j9x0q2...",
    "externalId": "1762671234567",
    "status": "paid",
    "payMethod": "card",
    "amounts": { "total": 9000, "vat": 818, "taxFree": 0 },
    "approval": { "approvalNo": "30012345", "approvedAt": "2026-11-09T08:30:12.000Z", ... },
    ...
  }
}

주문 조회

GET /api/v1/orders/{receiptId}
GET /api/v1/orders?from=2026-11-01T00:00:00%2B09:00&to=2026-11-10T00:00:00%2B09:00&status=paid&storeId=str_…&limit=50&cursor=rcpt_…

목록은 주문일시 내림차순, nextCursor 가 있으면 cursor 로 넘겨 다음 페이지를 받습니다. 웹훅을 받은 뒤 단건 조회로 값을 다시 확인하는 것을 권장합니다.

취소 반영 — POST /orders/{receiptId}/cancel

주문앱이 아닌 서버에서 취소 결과를 기록할 때 씁니다. (주문앱은 취소된 주문을 POST /orders 로 다시 보내면 됩니다.)

{ "approvalNo": "30019999", "approvalDateTime": "261109180000", "canceledAt": "2026-11-09T09:00:00Z" }

웹훅

이벤트: order.paid, order.canceled. 2xx 응답이면 성공, 그 외·10초 초과는 실패로 보고 1분 · 5분 · 30분 · 2시간 · 6시간 간격으로 다시 보냅니다. 같은 이벤트가 두 번 갈 수 있으니 id(whd_…)로 중복을 거르세요.

POST https://example.com/webhooks/wavpay
x-wavpay-event: order.paid
x-wavpay-delivery: whd_...
x-wavpay-signature: t=1762671234,v1=5f2c...e9

{
  "id": "whd_...",
  "event": "order.paid",
  "createdAt": "2026-11-09T08:30:13.000Z",
  "data": { "order": { "receiptId": "rcpt_...", ... } }
}

서명 검증 (Node.js):

import { createHmac, timingSafeEqual } from 'node:crypto';

// rawBody: 파싱 전 원문 문자열, secret: 콘솔의 whsec_...
export function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // 재전송 공격 방지
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? '');
  return a.length === b.length && timingSafeEqual(a, b);
}

에러 코드

에러 응답: { "errorCode": "AMOUNT_MISMATCH", "message": "…", "details": … }

errorCodeHTTP의미
AUTH_REQUIRED401Authorization 헤더가 필요합니다. (Basic base64(clientKey:secretKey))
AUTH_INVALID401연동키가 올바르지 않습니다.
KEY_EXPIRED401만료되었거나 폐기된 연동키입니다.
IP_BLOCKED403허용되지 않은 IP 입니다. 연동키의 IP 허용목록을 확인하세요.
INVALID_JSON400요청 본문이 JSON 이 아닙니다.
VALIDATION_FAILED422요청 값이 올바르지 않습니다.
AMOUNT_MISMATCH422주문 항목 합계와 결제 금액이 다릅니다.
DEVICE_NOT_FOUND404등록되지 않은 단말입니다. 콘솔 > 매장·단말에서 단말 id 를 확인하세요.
ORDER_NOT_FOUND404주문을 찾을 수 없습니다.
ORDER_CONFLICT409같은 주문 id 로 다른 내용의 주문이 이미 들어와 있습니다.
ALREADY_CANCELED409이미 취소된 주문입니다.
INTERNAL500서버 오류입니다. 잠시 후 다시 시도하세요.

재시도 기준: 5xx·네트워크 오류는 재시도, 4xx는 요청을 고쳐야 하므로 재시도하지 않습니다.