Skip to content

인증 · 서명 ​

Operator API 요청(오퍼레이터 → 플랫폼)과 지갑 콜백(플랫폼 → 오퍼레이터)은 같은 HMAC-SHA256 서명 규칙을 씁니다. 서명 함수 하나를 만들어 두면 요청 서명과 콜백 검증에 함께 쓸 수 있습니다.

헤더 ​

헤더Operator API (오퍼레이터가 보냄)지갑 콜백 (플랫폼이 보냄)값
Content-Type보냄보냄application/json; charset=utf-8
X-Operator필수보냄오퍼레이터 코드 (예: acme)
X-Api-Key항상 보내세요보내지 않음발급받은 API 키 (ak_…). 값이 틀리면 401 INVALID_SIGNATURE
X-Timestamp필수보냄요청 시각, Unix epoch 밀리초 10진 문자열 (예: 1760000000000)
X-Nonce필수보냄 (24자)요청마다 새로 만드는 임의 문자열, 1~128자
X-Signature필수보냄아래 서명문의 HMAC-SHA256, 소문자 hex 64자

헤더 이름은 대소문자를 구분하지 않습니다.

서명문 ​

서명문은 다섯 부분을 줄바꿈(\n, LF 한 글자)으로 이은 바이트열입니다. 마지막 본문 뒤에는 줄바꿈을 붙이지 않습니다.

X-Timestamp \n X-Nonce \n METHOD \n PATH_WITH_QUERY \n BODY
X-Signature = lowercase_hex( HMAC-SHA256( key = 서명 비밀키, message = 서명문 ) )
부분규칙
X-Timestamp헤더에 실은 값과 같은 문자열
X-Nonce헤더에 실은 값과 같은 문자열
METHOD대문자 HTTP 메서드 (GET, POST, PUT, PATCH, DELETE)
PATH_WITH_QUERY요청 줄에 실린 경로 + 쿼리 원문. 스킴 · 호스트는 빼고, 쿼리가 있으면 ? 부터 그대로 붙입니다. 퍼센트 인코딩도 보낸 그대로입니다. 예: /operator/v1/rounds?from=2026-10-01T00%3A00%3A00%2B08%3A00&limit=50
BODY실제로 보내는 본문 바이트 그대로. 본문이 없으면(GET 등) 빈 문자열
키발급받은 64자리 hex 비밀키 문자열의 바이트 그대로. hex 디코딩하지 않습니다.

Operator API 에서 ​

  • 본문은 직렬화한 바로 그 바이트로 서명하고 그 바이트를 보냅니다. 객체를 서명할 때 한 번, 보낼 때 또 한 번 직렬화하면 공백 · 키 순서가 달라져 서명이 깨질 수 있습니다.
  • GET 은 본문 없이 보내고 빈 문자열로 서명합니다.
  • 본문이 필요 없는 POST · DELETE 는 본문 없이(빈 문자열로 서명) 보내도 되고, {} 를 보내고 {} 로 서명해도 됩니다.
  • 쿼리 값은 직접 퍼센트 인코딩한 문자열을 URL 에 넣고, 같은 문자열로 서명합니다. 특히 시간대 오프셋의 + 는 %2B 로 인코딩해야 합니다. HTTP 라이브러리가 쿼리를 다시 인코딩하거나 순서를 바꾸면 서명이 맞지 않습니다.

지갑 콜백에서 ​

  • 플랫폼은 항상 POST 로 보내고, PATH_WITH_QUERY 는 등록된 지갑 URL 의 경로에 엔드포인트를 붙인 값입니다. 지갑 URL 이 https://api.acme.example/games-wallet 이면 debit 의 서명 경로는 /games-wallet/debit 입니다.
  • 검증할 때는 JSON 을 파싱하기 전의 원문 본문 바이트로 서명을 계산하세요. 파싱한 객체를 다시 직렬화하면 바이트가 달라집니다. 플랫폼은 JSON 키를 사전순으로 직렬화하지만, 그 순서에 기대지 말고 받은 바이트를 그대로 쓰세요.
  • 플랫폼은 같은 거래를 재시도할 때 본문은 처음과 같은 바이트로 다시 보내고, X-Timestamp · X-Nonce · X-Signature 만 새로 만듭니다.

타임스탬프 · nonce ​

규칙값
허용 오차수신 서버 시각 기준 ±5분. 넘으면 401 TIMESTAMP_OUT_OF_RANGE
nonce 길이1~128자. 비었거나 길면 401 INVALID_SIGNATURE
nonce 재사용같은 오퍼레이터의 같은 nonce 는 5분 안에 한 번만 받습니다. 두 번째부터 401 NONCE_REUSED
  • 서버 시계를 NTP 로 맞춰 두세요. 시계가 5분 넘게 어긋나면 모든 요청이 거절됩니다.
  • nonce 는 요청마다 새로 만듭니다. 16바이트 이상의 암호학적 난수를 hex 로 쓰기를 권장합니다.
  • 타임아웃 등으로 같은 요청을 다시 보낼 때도 타임스탬프 · nonce · 서명을 새로 만드세요. 첫 요청이 플랫폼에 닿아 nonce 가 이미 쓰였다면, 같은 nonce 로 다시 보낸 요청은 NONCE_REUSED 로 거절됩니다.

플랫폼의 검증 순서 (Operator API) ​

플랫폼은 Operator API 요청을 아래 순서로 검사하고, 처음 실패한 단계의 오류를 돌려줍니다.

순서검사실패 시
1X-Operator 의 오퍼레이터가 있는지401 INVALID_SIGNATURE
2오퍼레이터가 사용 중 상태인지403 OPERATOR_DISABLED
3요청 IP 가 등록된 IP 허용 목록 안인지403 IP_NOT_ALLOWED
4X-Api-Key 가 발급된 키와 같은지401 INVALID_SIGNATURE
5본문 읽기400 INVALID_REQUEST
6타임스탬프 형식 · ±5분401 TIMESTAMP_OUT_OF_RANGE
7서명 비교401 INVALID_SIGNATURE
8nonce 길이401 INVALID_SIGNATURE
9nonce 재사용401 NONCE_REUSED
10속도 제한 (오퍼레이터당 초당 200건)429 RATE_LIMITED

오류 응답 모양은 공통 규약을, 코드 전체는 오류 코드를 보세요.

지갑 콜백 검증 순서 (오퍼레이터 구현) ​

지갑 콜백을 받으면 업무 처리 전에 아래 순서로 검사하기를 권장합니다.

  1. X-Operator 가 자기 오퍼레이터 코드와 같은지 확인합니다.
  2. X-Timestamp 가 정수이고 자기 서버 시각과 ±5분 안인지 확인합니다.
  3. 원문 본문 바이트로 서명을 계산해 X-Signature 와 상수 시간 비교합니다(hmac.Equal, crypto.timingSafeEqual, hmac.compare_digest).
  4. 서명이 맞으면 nonce 를 5분 이상 보관하고, 이미 본 nonce 면 거절합니다. 서버가 여러 대이거나 재시작될 수 있으면 Redis · DB 같은 공유 저장소에 보관하세요(프로세스 메모리만 쓰면 재사용 방지가 약해집니다).
  5. 여기까지 통과한 뒤에 JSON 을 파싱하고 tx_id 멱등 처리 · 잔액 처리로 넘어갑니다.

검증에 실패하면 HTTP 200 + {"status":"INVALID_SIGNATURE"} 로 응답합니다. 상태별로 플랫폼이 어떻게 처리하는지는 지갑 콜백 개요를 보세요.

예시 코드 ​

서명 함수, Operator API 호출, 지갑 콜백 검증을 언어별로 보입니다. 환경변수 GAMES_API_BASE(환경별로 발급하는 {API_BASE} — 연동 절차), GAMES_OPERATOR_CODE, GAMES_API_KEY, GAMES_API_SECRET 을 쓴다고 가정합니다. 콜백 nonce 중복 검사는 저장소에 따라 다르므로 빠져 있습니다.

go
package games

import (
	"bytes"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"os"
	"strconv"
	"time"
)

// sign 은 서명문 = ts \n nonce \n METHOD \n pathWithQuery \n body 의 HMAC-SHA256 소문자 hex.
func sign(secret, ts, nonce, method, pathWithQuery string, body []byte) string {
	mac := hmac.New(sha256.New, []byte(secret)) // 비밀키 문자열 바이트 그대로
	mac.Write([]byte(ts + "\n" + nonce + "\n" + method + "\n" + pathWithQuery + "\n"))
	mac.Write(body)
	return hex.EncodeToString(mac.Sum(nil))
}

// Call 은 Operator API 를 서명해 부른다.
// pathWithQuery 는 퍼센트 인코딩을 마친 문자열 (예: "/operator/v1/rounds?limit=50").
func Call(method, pathWithQuery string, body []byte) (*http.Response, error) {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		return nil, err
	}
	nonce := hex.EncodeToString(b)
	ts := strconv.FormatInt(time.Now().UnixMilli(), 10)
	if body == nil {
		body = []byte{} // GET 은 빈 본문으로 서명
	}
	req, err := http.NewRequest(method, os.Getenv("GAMES_API_BASE")+pathWithQuery, bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json; charset=utf-8")
	req.Header.Set("X-Operator", os.Getenv("GAMES_OPERATOR_CODE"))
	req.Header.Set("X-Api-Key", os.Getenv("GAMES_API_KEY"))
	req.Header.Set("X-Timestamp", ts)
	req.Header.Set("X-Nonce", nonce)
	req.Header.Set("X-Signature", sign(os.Getenv("GAMES_API_SECRET"), ts, nonce, method, pathWithQuery, body))
	return http.DefaultClient.Do(req)
}

// VerifyCallback 은 지갑 콜백의 서명을 확인하고 원문 본문을 돌려준다 (nonce 중복 검사는 따로).
func VerifyCallback(r *http.Request) ([]byte, bool) {
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil || r.Header.Get("X-Operator") != os.Getenv("GAMES_OPERATOR_CODE") {
		return nil, false
	}
	ts := r.Header.Get("X-Timestamp")
	ms, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || time.Since(time.UnixMilli(ms)).Abs() > 5*time.Minute {
		return nil, false
	}
	// r.URL.RequestURI() = 요청 줄의 경로 + 쿼리 원문
	want := sign(os.Getenv("GAMES_API_SECRET"), ts, r.Header.Get("X-Nonce"), r.Method, r.URL.RequestURI(), body)
	return body, hmac.Equal([]byte(want), []byte(r.Header.Get("X-Signature")))
}
js
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.GAMES_API_SECRET; // 64자리 hex 문자열 그대로

// 서명문 = ts \n nonce \n METHOD \n pathWithQuery \n body
export function sign(ts, nonce, method, pathWithQuery, body) {
  return createHmac('sha256', SECRET)
    .update(`${ts}\n${nonce}\n${method}\n${pathWithQuery}\n`)
    .update(body) // string 은 UTF-8 로, Buffer 는 바이트 그대로
    .digest('hex');
}

// Operator API 호출. pathWithQuery 는 퍼센트 인코딩을 마친 문자열.
export async function callApi(method, pathWithQuery, payload) {
  const ts = String(Date.now());
  const nonce = randomBytes(16).toString('hex');
  const raw = payload === undefined ? '' : JSON.stringify(payload); // 보낼 바이트 = 서명할 바이트
  const res = await fetch(process.env.GAMES_API_BASE + pathWithQuery, {
    method,
    body: method === 'GET' ? undefined : raw,
    headers: {
      'Content-Type': 'application/json; charset=utf-8',
      'X-Operator': process.env.GAMES_OPERATOR_CODE,
      'X-Api-Key': process.env.GAMES_API_KEY,
      'X-Timestamp': ts,
      'X-Nonce': nonce,
      'X-Signature': sign(ts, nonce, method, pathWithQuery, raw),
    },
  });
  return res.json();
}

// 지갑 콜백 서명 확인. req 는 node:http 요청, rawBody 는 받은 본문 Buffer.
// (Express 라면 express.raw({ type: 'application/json' }) 로 원문을 받는다.) nonce 중복 검사는 따로.
export function verifyCallback(req, rawBody) {
  const ts = req.headers['x-timestamp'] ?? '';
  const nonce = req.headers['x-nonce'] ?? '';
  const sig = req.headers['x-signature'] ?? '';
  if (req.headers['x-operator'] !== process.env.GAMES_OPERATOR_CODE) return false;
  if (!/^\d+$/.test(ts) || Math.abs(Date.now() - Number(ts)) > 5 * 60 * 1000) return false;
  // req.url = 요청 줄의 경로 + 쿼리 원문 (Express 는 하위 라우터에서 바뀌므로 req.originalUrl)
  const want = Buffer.from(sign(ts, nonce, req.method, req.url, rawBody));
  const got = Buffer.from(String(sig));
  return want.length === got.length && timingSafeEqual(want, got);
}
python
import hashlib, hmac, json, os, secrets, time
import urllib.request

SECRET = os.environ["GAMES_API_SECRET"].encode()  # 64자리 hex 문자열 그대로


def sign(ts: str, nonce: str, method: str, path_with_query: str, body: bytes) -> str:
    """서명문 = ts \\n nonce \\n METHOD \\n path_with_query \\n body"""
    msg = f"{ts}\n{nonce}\n{method}\n{path_with_query}\n".encode() + body
    return hmac.new(SECRET, msg, hashlib.sha256).hexdigest()


def call_api(method: str, path_with_query: str, payload=None):
    """Operator API 호출. path_with_query 는 퍼센트 인코딩을 마친 문자열."""
    ts = str(int(time.time() * 1000))
    nonce = secrets.token_hex(16)
    raw = b"" if payload is None else json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode()
    req = urllib.request.Request(
        os.environ["GAMES_API_BASE"] + path_with_query,
        data=None if method == "GET" else raw,
        method=method,
        headers={
            "Content-Type": "application/json; charset=utf-8",
            "X-Operator": os.environ["GAMES_OPERATOR_CODE"],
            "X-Api-Key": os.environ["GAMES_API_KEY"],
            "X-Timestamp": ts,
            "X-Nonce": nonce,
            "X-Signature": sign(ts, nonce, method, path_with_query, raw),
        },
    )
    with urllib.request.urlopen(req, timeout=10) as res:
        return json.load(res)


def verify_callback(method: str, path_with_query: str, headers: dict, raw_body: bytes) -> bool:
    """지갑 콜백 서명 확인. headers 는 소문자 키, raw_body 는 받은 바이트 그대로. nonce 중복 검사는 따로."""
    ts = headers.get("x-timestamp", "")
    if headers.get("x-operator") != os.environ["GAMES_OPERATOR_CODE"]:
        return False
    if not ts.isdigit() or abs(time.time() * 1000 - int(ts)) > 5 * 60 * 1000:
        return False
    want = sign(ts, headers.get("x-nonce", ""), method, path_with_query, raw_body)
    return hmac.compare_digest(want, headers.get("x-signature", ""))

테스트 벡터 ​

구현을 확인할 수 있는 고정 입력과 기대 서명입니다. 아래 비밀키는 예시용 가짜 값입니다. 세 언어의 위 sign 함수 모두 같은 결과를 냅니다.

비밀키: 4f1c2a9e7b3d5f8061a2c4e6b8d0f1a3c5e7092b4d6f8a1c3e5072b4d6f8a9c1

1. POST 요청 (게임 실행) ​

항목값
X-Timestamp1760000000000
X-Nonce9b1f0c7e2d4a6b8c0e1f2a3b
METHODPOST
PATH_WITH_QUERY/operator/v1/launch

본문 (공백 없이 한 줄):

json
{"player":{"external_id":"user_1001","nickname":"Lucky7","currency":"PHP"},"game_code":"holdem_nlh","lang":"en"}
X-Signature = 376a774f812e82f8e63532237da598e05f229c2c8caf1cc858d12eb949c255e5

2. GET 요청 (인코딩된 쿼리) ​

항목값
X-Timestamp1760000000000
X-Nonce1a2b3c4d5e6f7a8b9c0d1e2f
METHODGET
PATH_WITH_QUERY/operator/v1/rounds?from=2026-10-01T00%3A00%3A00%2B08%3A00&limit=50
본문(빈 문자열)
X-Signature = 2d61631759075b2049547a65929805acf090e9088f94e4a83dbfb896b4d53914

3. 지갑 콜백 (debit) ​

지갑 URL 이 https://api.acme.example/wallet 인 경우입니다.

항목값
X-Timestamp1760000000000
X-Noncec0ffee00c0ffee00c0ffee00
METHODPOST
PATH_WITH_QUERY/wallet/debit

본문 (공백 없이 한 줄):

json
{"amount":5000,"created_at":"2025-10-09T08:53:20.000Z","currency":"PHP","external_id":"user_1001","game_code":"live_baccarat","operator_session_id":null,"player_id":"pl_0192f3a1c4e87b2c9d0e1f2a3b4c5d6f","reason":"bet","round_id":"rnd_0192f3a1c4e87b2c9d0e1f2a3b4c5d71","table_id":"tbl_0192f3a1c4e87b2c9d0e1f2a3b4c5d70","tx_id":"tx_0192f3a1c4e87b2c9d0e1f2a3b4c5d6e"}
X-Signature = 3f54f0c9c193f8234fd5bc296c48a6ed41d7221a9af0a5bf94b5d5aba43d6600

서명문 직접 확인

1번의 서명문은 아래 다섯 줄입니다(마지막 줄 뒤에 줄바꿈 없음). 결과가 다르면 줄바꿈 문자(\r\n 이 아니라 \n)와 본문 바이트부터 확인하세요.

1760000000000
9b1f0c7e2d4a6b8c0e1f2a3b
POST
/operator/v1/launch
{"player":{"external_id":"user_1001","nickname":"Lucky7","currency":"PHP"},"game_code":"holdem_nlh","lang":"en"}

흔한 실수 ​

증상원인
모든 요청이 INVALID_SIGNATURE비밀키를 hex 디코딩해 키로 썼습니다. 64자리 문자열 그대로 써야 합니다.
POST 만 INVALID_SIGNATURE서명한 본문과 보낸 본문이 다릅니다(두 번 직렬화, 라이브러리가 본문을 다시 직렬화). 직렬화한 바이트 하나로 서명하고 보내세요.
쿼리가 있는 GET 만 INVALID_SIGNATURE서명한 경로와 실제 요청 줄이 다릅니다. 라이브러리에 쿼리를 객체로 넘기면 인코딩 · 순서가 바뀔 수 있습니다. 인코딩을 마친 문자열을 URL 과 서명에 똑같이 쓰세요.
기간 조회가 조용히 기본값(최근 24시간)으로 나옴from=…+08:00 의 + 를 인코딩하지 않아 공백으로 읽혔습니다. %2B 로 보내세요.
대문자 hex 서명이 거절됨서명은 소문자 hex 로 비교합니다.
TIMESTAMP_OUT_OF_RANGE초 단위 타임스탬프를 보냈거나 서버 시계가 어긋났습니다. 밀리초로 보내고 NTP 를 맞추세요.
재시도한 요청이 NONCE_REUSED같은 nonce 를 다시 썼습니다. 재시도 때도 nonce · 타임스탬프 · 서명을 새로 만드세요.
지갑 콜백 검증이 운영 환경에서만 실패리버스 프록시가 경로 앞부분을 잘라 앱에 넘깁니다. 플랫폼은 등록된 지갑 URL 의 원래 경로로 서명하므로, 앱이 받은 경로가 다르면 원래 경로로 바꿔 검증하세요.
지갑 콜백 검증이 가끔 실패파싱한 JSON 을 다시 직렬화해 검증했습니다. 원문 바이트로 검증하세요.
큰 본문만 INVALID_SIGNATURE본문 상한(64KB)을 넘어 잘린 채 검증됐습니다. 공통 규약 참고

문서 v1.0.0 · Operator API v1