개발자/시작하기

인증

모든 API 요청에는 Merchant ID와 API 키가 들어가야 합니다. 주문 요청(POST/orders)에는 서명도 함께 보냅니다. 서명이 있으면 누구도 주문 내용을 바꾸거나 같은 요청을 다시 보낼 수 없습니다.

관련 문서: 상품과 주문 · 보안 · README

#인증 정보

항목예시비밀 정보인가요?
Merchant IDM00000001아니요. 귀사를 식별하는 값입니다.
API 키cvb2b_...예. 비밀번호이자 서명 키입니다.
  • Merchant ID는 계정이 승인되면 CardV가 발급합니다.
  • API 키는 Owner가 포털에서 만듭니다(API 키 참고).
  • Live와 Sandbox는 키가 서로 다릅니다. 한쪽 키는 다른 쪽에서 쓸 수 없습니다.
  • API는 CardV가 사업자 심사를 승인한 뒤부터 사용할 수 있습니다. 승인되면 GET/account 응답에 "api_access_enabled": true가 표시됩니다.

#요청 헤더

모든 요청에 다음 두 헤더를 보냅니다.

HTTP
X-Merchant-Id: M00000001
X-Api-Key: cvb2b_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

POST/orders에는 다음 세 헤더를 추가로 보냅니다.

HTTP
X-Timestamp: 1790000000
X-Nonce: 0123456789abcdef0123456789abcdef
X-Signature: fd55fa31a3b6817d5368903dbb4bad4458381e024c17e688580bac96577acfed
  • X-Timestamp는 현재 Unix 시간(초)입니다. CardV 서버 시각과 5분(300초) 이상 차이 나면 안 됩니다.
  • X-Nonce는 한 번만 쓰는 임의의 값입니다. 16진수 문자 32개로 된 난수를 사용하세요.
  • X-Signature는 주문 서명에서 설명합니다. 소문자 16진수여야 합니다.

GET 요청에는 서명이 필요 없습니다.

#주문 서명

  1. 주문 내용을 JSON 문자열로 한 번만 만듭니다. 서명할 때와 전송할 때 이 바이트를 그대로 씁니다.

  2. 본문 해시를 구합니다. BODY_HASH = 본문의 SHA-256 값(소문자 16진수)

  3. 아래 다섯 줄을 줄바꿈(\n)으로 이어 붙입니다. 마지막 줄 뒤에는 줄바꿈을 넣지 않습니다.

    Text
    POST
    /api/v1/orders
    <X-Timestamp>
    <X-Nonce>
    <BODY_HASH>
  4. 서명합니다. X-Signature = 위 문자열의 HMAC-SHA256 값이며, 키로는 API 키 전체를 사용합니다. 결과는 소문자 16진수로 표기합니다.

가장 흔한 실수는 서명한 JSON과 실제로 보낸 JSON이 다른 경우입니다. 예를 들어 {"a":1}과 {"a": 1}은 바이트가 다릅니다. 서명한 뒤 HTTP 라이브러리가 본문을 바꾸지 않는지 꼭 확인하세요.

테스트 값. 아래 값으로 코드를 검증하세요. 키는 가짜입니다.

Text
API key      cvb2b_TEST_ONLY_not_a_real_key_0123456789abcdef
Method       POST
Path         /api/v1/orders
X-Timestamp  1790000000
X-Nonce      0123456789abcdef0123456789abcdef

본문(108바이트, 한 줄, 끝에 줄바꿈 없음):

JSON
{"external_order_id":"TEST-0001","items":[{"sku_id":"S000001","quantity":1,"expected_unit_price":"9.2500"}]}

올바른 결과:

Text
BODY_HASH    b0545ae25d54b219f27d8bd90e4dcbf26cf0d491f53982da67b8eef0a1a59960
X-Signature  fd55fa31a3b6817d5368903dbb4bad4458381e024c17e688580bac96577acfed

#예제 코드

아래 예제는 모두 위 테스트 값과 같은 결과를 냅니다. API 키는 코드에 적지 말고 시크릿 저장소에서 불러오세요.

cURL (bash + OpenSSL)

Shell
BASE_URL="https://sandbox.cardv.net"
REQ_PATH="/api/v1/orders"      # do not call this variable PATH
BODY='{"external_order_id":"SHOP-10001","items":'
BODY+='[{"sku_id":"S000001","quantity":1,"expected_unit_price":"9.2500"}]}'
TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.*= //')
SIG=$(printf 'POST\n%s\n%s\n%s\n%s' "$REQ_PATH" "$TS" "$NONCE" "$BODY_HASH" \
  | openssl dgst -sha256 -hmac "$CARDV_API_KEY" -hex | sed 's/^.*= //')

curl -sS -X POST "$BASE_URL$REQ_PATH" \
  -H "Content-Type: application/json" \
  -H "X-Merchant-Id: $CARDV_MERCHANT_ID" \
  -H "X-Api-Key: $CARDV_API_KEY" \
  -H "X-Timestamp: $TS" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: $SIG" \
  --data-raw "$BODY"

-d @file 대신 --data-raw를 쓰세요. -d는 줄바꿈을 지우기 때문에 실제로 전송되는 바이트가 서명과 달라집니다.

Python (requests)

Python
import hashlib, hmac, json, os, secrets, time
import requests

BASE_URL = "https://sandbox.cardv.net"
MERCHANT_ID = os.environ["CARDV_MERCHANT_ID"]
API_KEY = os.environ["CARDV_API_KEY"]
AUTH = {"X-Merchant-Id": MERCHANT_ID, "X-Api-Key": API_KEY}


def sign(path: str, body: bytes, ts: str, nonce: str) -> str:
    body_hash = hashlib.sha256(body).hexdigest()
    text = "\n".join(["POST", path, ts, nonce, body_hash])
    return hmac.new(API_KEY.encode(), text.encode(), hashlib.sha256).hexdigest()


def cardv_get(path: str, params=None) -> requests.Response:
    return requests.get(BASE_URL + path, params=params, headers=AUTH, timeout=30)


def cardv_post(path: str, payload: dict) -> requests.Response:
    body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode()
    ts, nonce = str(int(time.time())), secrets.token_hex(16)
    headers = {
        **AUTH,
        "Content-Type": "application/json",
        "X-Timestamp": ts,
        "X-Nonce": nonce,
        "X-Signature": sign(path, body, ts, nonce),
    }
    return requests.post(BASE_URL + path, data=body, headers=headers, timeout=30)


resp = cardv_post("/api/v1/orders", {
    "external_order_id": "SHOP-10001",
    "items": [{"sku_id": "S000001", "quantity": 1, "expected_unit_price": "9.2500"}],
})
print(resp.status_code, resp.json())

order_id = resp.json()["order"]["order_id"]
print(cardv_get(f"/api/v1/orders/{order_id}").json()["status"])

Node.js 18+ (내장 fetch)

Node.js
import crypto from "node:crypto";

const BASE_URL = "https://sandbox.cardv.net";
const { CARDV_MERCHANT_ID, CARDV_API_KEY } = process.env;
const AUTH = { "X-Merchant-Id": CARDV_MERCHANT_ID, "X-Api-Key": CARDV_API_KEY };

function sign(path, body, timestamp, nonce) {
  const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
  const text = ["POST", path, timestamp, nonce, bodyHash].join("\n");
  return crypto.createHmac("sha256", CARDV_API_KEY).update(text, "utf8").digest("hex");
}

async function cardvPost(path, payload) {
  const body = Buffer.from(JSON.stringify(payload), "utf8"); // serialize once
  const timestamp = String(Math.floor(Date.now() / 1000));
  const nonce = crypto.randomBytes(16).toString("hex");
  const headers = {
    ...AUTH,
    "Content-Type": "application/json",
    "X-Timestamp": timestamp,
    "X-Nonce": nonce,
    "X-Signature": sign(path, body, timestamp, nonce),
  };
  const res = await fetch(BASE_URL + path, { method: "POST", headers, body });
  return { status: res.status, body: await res.json() };
}

console.log(await cardvPost("/api/v1/orders", {
  external_order_id: "SHOP-10001",
  items: [{ sku_id: "S000001", quantity: 1, expected_unit_price: "9.2500" }],
}));

PHP (서명 부분만)

PHP
<?php
function cardv_sign(
    string $apiKey, string $path, string $body, string $ts, string $nonce
): string {
    $text = implode("\n", ['POST', $path, $ts, $nonce, hash('sha256', $body)]);
    return hash_hmac('sha256', $text, $apiKey); // lowercase hex
}

// Send exactly $body.
$body = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$ts = (string) time();
$nonce = bin2hex(random_bytes(16));
$signature = cardv_sign($apiKey, '/api/v1/orders', $body, $ts, $nonce);

#오류

아래 오류는 모두 HTTP 403(401이 아님)을 반환합니다. 요청 한도 초과만 429를 반환합니다. 응답 본문은 다음과 같습니다.

JSON
{"detail": "Invalid HMAC signature."}
문제해결 방법
Merchant ID 또는 API 키가 없거나 틀림두 헤더와 환경(Live/Sandbox)을 확인하세요.
API 사용이 아직 허용되지 않음CardV의 사업자 심사 승인을 기다리세요.
서버 IP가 허용되지 않음포털의 IP 허용 목록에 추가하세요.
포털 전용 엔드포인트포털에서 처리하세요. API 키로는 7개 엔드포인트만 쓸 수 있습니다.
서명이 없거나 틀림서명 코드를 고치고 테스트 값으로 검증하세요.
타임스탬프가 너무 오래됐거나 미래 시각임서버 시계를 동기화하세요(NTP).
이미 사용한 nonce주문 요청마다 새 nonce를 만드세요.
요청이 너무 많음(429)Retry-After에 적힌 초만큼 기다린 뒤 다시 보내세요.

꼭 알아 두세요.

  • 주문을 다시 보낼 때는 타임스탬프, nonce, 서명을 새로 만드세요. 본문은 그대로 둡니다. 안전한 재시도를 참고하세요.
  • 서명이 틀린 요청이라도 한 번 보낸 nonce는 다시 쓸 수 없습니다.
  • CardV는 실패한 요청을 기록하며, 실패가 많으면 담당 팀에 알림이 갑니다.

#IP 허용 목록

IP 허용 목록을 설정하면 귀사 서버에서만 API 키를 쓸 수 있습니다. 포털에서 Owner가 관리합니다.

  • 선택 사항입니다. 규칙이 없으면 모든 IP의 요청을 받습니다.
  • 규칙이 하나라도 있으면, 목록에 없는 IP의 요청은 HTTP 403을 받습니다.
  • 규칙은 203.0.113.10/32(IPv4)나 2001:db8::/48(IPv6) 형식으로 씁니다.
  • API 키 요청에만 적용되며, 포털 로그인에는 적용되지 않습니다.
  • 켜기 전에 NAT 게이트웨이와 백업 리전을 포함해 모든 서버 IP를 먼저 등록하세요.

#API 키

API 키로는 API 한눈에 보기에 나온 7개 엔드포인트만 호출할 수 있습니다. 여기에는 주문과 코드 조회도 포함되므로, Owner 비밀번호처럼 철저히 관리하세요.

키는 Owner만 관리할 수 있으며, 포털의 Integrations → API keys에서 관리합니다. Live와 Sandbox에서 각각 따로 설정합니다.

  1. 만들기: 키를 만들고 이름을 붙입니다. 이 단계에서는 키가 아직 표시되지 않습니다.
  2. 확인(Reveal): CardV가 6자리 인증 코드를 이메일로 보냅니다. 코드는 10분간 유효하며 5번까지 입력할 수 있습니다. 코드를 입력하면 키 전체가 표시됩니다. 바로 저장하세요. 키를 확인할 때마다 기록이 남습니다.
  3. 비활성화: 더 이상 쓰지 않는 키는 비활성화합니다. 즉시 적용되며 되돌릴 수 없습니다.

서비스 중단 없이 키를 교체하는 방법:

  1. 새 키를 만들고 확인합니다.
  2. 모든 서버에 새 키를 배포합니다.
  3. 포털에서 이전 키가 더 이상 쓰이지 않는지 확인합니다.
  4. 이전 키를 비활성화합니다.

키는 최소 1년에 한 번, 그리고 키에 접근할 수 있던 사람이 퇴사할 때마다 교체하세요. 키가 유출됐을 수 있다면 먼저 비활성화한 다음 원인을 조사하세요.

연동 관련 문의는 Merchant ID와 주문 ID 또는 요청 ID를 포함해 [email protected]로 보내 주세요.