본문 바로가기
빌링플러스개발자센터
홈 로그인 도입 상담

시작하기

인증 · 요청 서명

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-KeyPOST 요청에만, 필수 — 멱등키
Content-Type본문이 있으면 application/json; charset=utf-8

서명 문자열

다섯 값을 줄바꿈(\n, LF 한 글자)으로 잇습니다. 마지막 줄 뒤에는 줄바꿈을 붙이지 않습니다.

서명 문자열
{METHOD}\n{경로+쿼리}\n{timestamp}\n{nonce}\n{sha256(본문) 16진수}

서명 = hex( HMAC-SHA256( 시크릿 키, 서명 문자열 ) )
줄값예
1HTTP 메서드, 대문자GET, POST, PATCH, DELETE
2경로 + 쿼리스트링. 도메인은 빼고 /api/v1 부터. 쿼리가 있으면 ? 부터 보내는 그대로/api/v1/customers?limit=5
3X-BillingPlus-Timestamp 에 보낸 값1793145600
4X-BillingPlus-Nonce 에 보낸 값3f9a1c7e5b2d4a6f8e0c1b3d5f7a9e2c
5보내는 본문 바이트의 SHA-256, 소문자 16진수. 본문이 없으면 빈 값의 해시e3b0c442…b855(빈 본문)

언어별 예제

아래 네 예제는 같은 입력에 대해 빌링플러스 서버와 같은 서명을 만드는 것을 실제로 실행해 확인했습니다. 각 예제의 sign 함수가 핵심이고, 나머지는 요청을 보내는 예시입니다.

Python
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
// 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
// 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
// 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
메서드 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 으로 시험하세요.

401
{
  "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)으로 적습니다.