시작하기
인증 · 요청 서명
X-BillingPlus 헤더와 HMAC-SHA256 요청 서명 규칙, 언어별 서명 예제(Python·Node.js·PHP·Java).
모든 API 요청은 API 키와 시크릿 키로 만든 HMAC-SHA256 서명을 함께 보냅니다. 서명은 요청 내용(메서드·경로·본문)과 시각·난수를 묶어 계산하므로, 중간에서 값을 바꾸거나 같은 요청을 다시 보내면 거부됩니다. 어느 가맹점의 요청인지는 API 키로만 정해집니다 — 요청 본문에 가맹점을 적는 칸은 없습니다.
요청 헤더
| 헤더 | 값 |
|---|---|
X-BillingPlus-Key | 발급받은 API 키(bpk_live_ 로 시작) |
X-BillingPlus-Timestamp | 요청 시각, 유닉스 시간 초(예: 1793145600). 서버 시각과 ±5분 안이어야 합니다. |
X-BillingPlus-Nonce | 요청마다 새로 만드는 난수. 16~128자, 32자 16진수를 권장합니다. 같은 값은 10분 동안 다시 쓸 수 없습니다. |
X-BillingPlus-Signature | 아래 서명 문자열의 HMAC-SHA256 값, 소문자 16진수 64자 |
Idempotency-Key | POST 요청에만, 필수 — 멱등키 |
Content-Type | 본문이 있으면 application/json; charset=utf-8 |
서명 문자열
다섯 값을 줄바꿈(\n, LF 한 글자)으로 잇습니다. 마지막 줄 뒤에는 줄바꿈을 붙이지 않습니다.
{METHOD}\n{경로+쿼리}\n{timestamp}\n{nonce}\n{sha256(본문) 16진수}
서명 = hex( HMAC-SHA256( 시크릿 키, 서명 문자열 ) )| 줄 | 값 | 예 |
|---|---|---|
| 1 | HTTP 메서드, 대문자 | GET, POST, PATCH, DELETE |
| 2 | 경로 + 쿼리스트링. 도메인은 빼고 /api/v1 부터. 쿼리가 있으면 ? 부터 보내는 그대로 | /api/v1/customers?limit=5 |
| 3 | X-BillingPlus-Timestamp 에 보낸 값 | 1793145600 |
| 4 | X-BillingPlus-Nonce 에 보낸 값 | 3f9a1c7e5b2d4a6f8e0c1b3d5f7a9e2c |
| 5 | 보내는 본문 바이트의 SHA-256, 소문자 16진수. 본문이 없으면 빈 값의 해시 | e3b0c442…b855(빈 본문) |
언어별 예제
아래 네 예제는 같은 입력에 대해 빌링플러스 서버와 같은 서명을 만드는 것을 실제로 실행해 확인했습니다. 각 예제의 sign 함수가 핵심이고, 나머지는 요청을 보내는 예시입니다.
import hashlib
import hmac
import json
import os
import secrets
import time
from urllib.parse import urlsplit
import requests
API_BASE = "https://api.billingplus.co.kr/api/v1"
API_KEY = os.environ["BILLINGPLUS_API_KEY"] # bpk_live_ 로 시작
API_SECRET = os.environ["BILLINGPLUS_API_SECRET"] # bos_ 로 시작 — 서버에만 둡니다
def sign(method: str, path_with_query: str, timestamp: str, nonce: str,
body: bytes, secret: str) -> str:
body_hash = hashlib.sha256(body).hexdigest() # 본문이 없으면 b"" 의 해시
message = "\n".join([method.upper(), path_with_query, timestamp, nonce, body_hash])
return hmac.new(secret.encode("utf-8"), message.encode("utf-8"),
hashlib.sha256).hexdigest()
def call(method: str, path: str, payload=None, idempotency_key: str | None = None):
"""path 예: "/ping", "/customers?limit=5" — 기준 주소 뒤에 붙는 부분"""
method = method.upper()
url = API_BASE + path
parts = urlsplit(url)
path_with_query = parts.path + (f"?{parts.query}" if parts.query else "")
body = b"" if payload is None else json.dumps(
payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
timestamp = str(int(time.time())) # 밀리초가 아니라 초
nonce = secrets.token_hex(16) # 요청마다 새 32자
headers = {
"X-BillingPlus-Key": API_KEY,
"X-BillingPlus-Timestamp": timestamp,
"X-BillingPlus-Nonce": nonce,
"X-BillingPlus-Signature": sign(method, path_with_query, timestamp, nonce,
body, API_SECRET),
}
if body:
headers["Content-Type"] = "application/json; charset=utf-8"
if method == "POST":
if not idempotency_key:
raise ValueError("POST 에는 Idempotency-Key 가 필요합니다. "
"재시도 때 같은 값을 쓰도록 미리 만들어 저장하세요.")
headers["Idempotency-Key"] = idempotency_key
# 서명한 바이트를 그대로 보냅니다. json= 으로 넘기면 다시 직렬화되어 서명이 어긋날 수 있습니다.
return requests.request(method, url, data=body, headers=headers, timeout=(5, 60))
if __name__ == "__main__":
print(call("GET", "/ping").json())// Node.js 18 이상(fetch 내장)
const crypto = require('node:crypto');
const API_BASE = 'https://api.billingplus.co.kr/api/v1';
const API_KEY = process.env.BILLINGPLUS_API_KEY; // bpk_live_ 로 시작
const API_SECRET = process.env.BILLINGPLUS_API_SECRET; // bos_ 로 시작 — 서버에만 둡니다
function sign(method, pathWithQuery, timestamp, nonce, body, secret) {
const bodyHash = crypto.createHash('sha256').update(body).digest('hex'); // 본문이 없으면 '' 의 해시
const message = [method.toUpperCase(), pathWithQuery, timestamp, nonce, bodyHash].join('\n');
return crypto.createHmac('sha256', secret).update(message, 'utf8').digest('hex');
}
async function call(method, path, payload, idempotencyKey) {
method = method.toUpperCase();
const url = new URL(API_BASE + path); // path 예: '/ping', '/customers?limit=5'
const body = payload === undefined ? Buffer.alloc(0) : Buffer.from(JSON.stringify(payload), 'utf8');
const timestamp = String(Math.floor(Date.now() / 1000)); // 밀리초가 아니라 초
const nonce = crypto.randomBytes(16).toString('hex'); // 요청마다 새 32자
const headers = {
'X-BillingPlus-Key': API_KEY,
'X-BillingPlus-Timestamp': timestamp,
'X-BillingPlus-Nonce': nonce,
'X-BillingPlus-Signature': sign(method, url.pathname + url.search, timestamp, nonce, body, API_SECRET),
};
if (body.length) headers['Content-Type'] = 'application/json; charset=utf-8';
if (method === 'POST') {
if (!idempotencyKey) throw new Error('POST 에는 Idempotency-Key 가 필요합니다.');
headers['Idempotency-Key'] = idempotencyKey;
}
const res = await fetch(url, { method, headers, body: body.length ? body : undefined });
return { status: res.status, requestId: res.headers.get('x-request-id'), body: await res.json() };
}
call('GET', '/ping').then((r) => console.log(r));<?php
// PHP 7.4 이상, curl 확장
const API_BASE = 'https://api.billingplus.co.kr/api/v1';
function bp_sign(string $method, string $pathWithQuery, string $timestamp, string $nonce,
string $body, string $secret): string {
$bodyHash = hash('sha256', $body); // 본문이 없으면 '' 의 해시
$message = implode("\n", [strtoupper($method), $pathWithQuery, $timestamp, $nonce, $bodyHash]);
return hash_hmac('sha256', $message, $secret);
}
function bp_call(string $method, string $path, ?array $payload = null, ?string $idempotencyKey = null): array {
$method = strtoupper($method);
$url = API_BASE . $path; // $path 예: '/ping', '/customers?limit=5'
$parts = parse_url($url);
$pathWithQuery = $parts['path'] . (isset($parts['query']) ? '?' . $parts['query'] : '');
$body = $payload === null ? '' : json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$timestamp = (string) time(); // 초
$nonce = bin2hex(random_bytes(16)); // 요청마다 새 32자
$headers = [
'X-BillingPlus-Key: ' . getenv('BILLINGPLUS_API_KEY'),
'X-BillingPlus-Timestamp: ' . $timestamp,
'X-BillingPlus-Nonce: ' . $nonce,
'X-BillingPlus-Signature: ' . bp_sign($method, $pathWithQuery, $timestamp, $nonce, $body,
getenv('BILLINGPLUS_API_SECRET')),
];
if ($body !== '') {
$headers[] = 'Content-Type: application/json; charset=utf-8';
}
if ($method === 'POST') {
if (!$idempotencyKey) {
throw new InvalidArgumentException('POST 에는 Idempotency-Key 가 필요합니다.');
}
$headers[] = 'Idempotency-Key: ' . $idempotencyKey;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $body, // 서명한 문자열 그대로
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 60,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return [$status, json_decode((string) $response, true)];
}
print_r(bp_call('GET', '/ping'));// Java 17 이상
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class BillingPlusClient {
static final String API_BASE = "https://api.billingplus.co.kr/api/v1";
static final String API_KEY = System.getenv("BILLINGPLUS_API_KEY"); // bpk_live_ 로 시작
static final String API_SECRET = System.getenv("BILLINGPLUS_API_SECRET"); // bos_ 로 시작
static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
static final SecureRandom RANDOM = new SecureRandom();
static String sign(String method, String pathWithQuery, String timestamp, String nonce,
byte[] body, String secret) throws Exception {
String bodyHash = HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(body)); // 본문이 없으면 new byte[0]
String message = String.join("\n", method.toUpperCase(), pathWithQuery, timestamp, nonce, bodyHash);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(message.getBytes(StandardCharsets.UTF_8)));
}
/** path 예: "/ping", "/customers?limit=5" — 본문이 없으면 json 은 null */
static HttpResponse<String> call(String method, String path, String json, String idempotencyKey)
throws Exception {
method = method.toUpperCase();
URI uri = URI.create(API_BASE + path);
String pathWithQuery = uri.getRawPath() + (uri.getRawQuery() != null ? "?" + uri.getRawQuery() : "");
byte[] body = json == null ? new byte[0] : json.getBytes(StandardCharsets.UTF_8);
String timestamp = String.valueOf(System.currentTimeMillis() / 1000); // 초
byte[] random = new byte[16];
RANDOM.nextBytes(random);
String nonce = HexFormat.of().formatHex(random); // 요청마다 새 32자
HttpRequest.Builder request = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(60))
.header("X-BillingPlus-Key", API_KEY)
.header("X-BillingPlus-Timestamp", timestamp)
.header("X-BillingPlus-Nonce", nonce)
.header("X-BillingPlus-Signature", sign(method, pathWithQuery, timestamp, nonce, body, API_SECRET))
.method(method, body.length == 0 ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofByteArray(body));
if (body.length > 0) {
request.header("Content-Type", "application/json; charset=utf-8");
}
if (method.equals("POST")) {
if (idempotencyKey == null || idempotencyKey.isEmpty()) {
throw new IllegalArgumentException("POST 에는 Idempotency-Key 가 필요합니다.");
}
request.header("Idempotency-Key", idempotencyKey);
}
return HTTP.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
}
public static void main(String[] args) throws Exception {
HttpResponse<String> response = call("GET", "/ping", null, null);
System.out.println(response.statusCode() + " " + response.body());
}
}내 구현이 맞는지 확인하기
아래 값을 넣었을 때 결과가 같으면 서명 계산은 맞습니다. 시크릿 키는 형식만 흉내 낸 예시 값입니다.
시크릿 키 bos_EXAMPLE-only-0123456789abcdefghijklmnopqrstuv
메서드 POST
경로 /api/v1/ping
timestamp 1793145600
nonce 8b2e4d6f0a1c3e5b7d9f1a3c5e7b9d0f
본문 {"hello":"빌링"} (UTF-8, 공백 없음)
본문 해시 2a4b7861492545809d99ffb40d8d0fb979e47f4ddde8ba0e5c62fe30998e8786
서명 f2933eadec4a1af0d23270d3ff539b2c97373928b8a679d4e386732a9b1f6b85메서드 GET · 경로 /api/v1/ping · timestamp 1793145600 · nonce 3f9a1c7e5b2d4a6f8e0c1b3d5f7a9e2c · 본문 없음
본문 해시 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
서명 cb41d48e6ef14484281a047a0b22f9d3ba3e12be04889f84c85fc094740555d2흔한 실수
쿼리스트링을 빼고 서명하면 401 입니다. GET /api/v1/customers?limit=5 는 /api/v1/customers?limit=5 전체로 서명합니다. 쿼리를 빼면 중간에서 limit=5 를 limit=100 으로 바꿔도 서명이 통과하게 되므로 막아 두었습니다.
한글처럼 인코딩이 필요한 값은 실제로 보내는 인코딩된 모양 그대로 서명합니다(?q=%ED%99%8D). 서명한 뒤에 HTTP 라이브러리가 주소를 다시 인코딩하지 않도록, 주소를 먼저 완성하고 그 주소로 서명하세요.
서명한 본문 바이트와 보내는 본문 바이트가 같아야 합니다. 객체를 넘기면 라이브러리가 JSON 을 다시 만들면서 공백·키 순서·한글 이스케이프가 달라질 수 있습니다. 문자열(바이트)로 만든 본문을 서명하고, 그 바이트를 그대로 보내세요(Python requests 는 json= 대신 data=).
본문이 없어도 다섯째 줄은 비우지 않습니다. 빈 값의 SHA-256 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 을 넣습니다.
그 밖에 자주 틀리는 곳
- 서명은 소문자 16진수로 보냅니다. 서버는 바이트 그대로 비교하므로 대문자면 다른 값입니다. Base64 도 아닙니다.
- timestamp 는 초입니다. JavaScript
Date.now()는 밀리초라 1000 으로 나눠야 합니다. - nonce 는 요청마다 새로 만듭니다. 재시도할 때도 새 nonce · 새 timestamp 로 서명을 다시 계산합니다(멱등키만 그대로 둡니다).
- 경로에서
/api/v1을 빠뜨리지 않습니다. 기준 주소https://api.billingplus.co.kr/api/v1의 경로 부분이 서명에 들어갑니다. - 메서드는 대문자입니다(
post가 아니라POST).
인증 실패 응답
키가 없거나, 서명이 틀렸거나, 시각이 벗어났거나, nonce 를 다시 썼거나 — 이유와 상관없이 모두 같은 401 UNAUTHORIZED 입니다. 어느 단계에서 막혔는지 알려 주면 공격자에게 힌트가 되기 때문입니다. 원인을 좁힐 때는 위의 검증용 값으로 서명 계산부터 확인하고, GET /api/v1/ping 으로 시험하세요.
{
"error": {
"code": "UNAUTHORIZED",
"message": "인증에 실패했습니다. API 키와 서명을 확인해 주세요.",
"detail": {},
"request_id": "req_4f1c9a2b7d3e5f60a1b2c3d4"
}
}예외는 IP 제한 하나입니다. 키에 허용 IP 를 등록했고 서명까지 맞았는데 다른 IP 에서 호출하면 403 FORBIDDEN_IP 와 함께 호출한 IP(detail.ip)를 알려 줍니다. 서버를 옮겼다면 API 연동 화면의 허용 IP 를 먼저 고치세요.
시크릿 키 관리
- 시크릿 키는 서버에만 둡니다. 브라우저 스크립트·모바일 앱에 넣으면 누구나 꺼내 쓸 수 있습니다. 브라우저에서 필요한 일(예: 등록창 열기)은 가맹점 서버를 거쳐 호출합니다.
- 잃어버렸거나 샌 것 같으면 새 키를 발급하고 옛 키를 폐기합니다. 폐기는 즉시 반영됩니다.
- 연동 시스템마다 키를 따로 쓰면(활성 키 최대 4개) 한 시스템의 키를 바꿀 때 다른 시스템이 멈추지 않습니다. 웹훅도 키마다 따로 받습니다.
- 허용 IP 를 등록하면 키가 새도 다른 곳에서 쓸 수 없습니다. IP 하나 또는 대역(
203.0.113.0/24)으로 적습니다.
개발자센터