JavaScript (Node.js) SDK 예제
이 페이지에서는 Ruby Team API를 위한 완전한 Node.js 연동 예제를 제공합니다. HMAC-SHA256 서명 유틸리티, axios를 사용한 HTTP 클라이언트 래퍼, 그리고 Seamless Wallet 콜백(Callback) 검증을 위한 Express 미들웨어를 포함합니다.
의존성: axios (npm install axios로 설치)
서명 유틸리티 및 HTTP 클라이언트
const crypto = require("crypto");
const axios = require("axios");
class RubyApiClient {
/**
* HTTP client for the Ruby Team API with automatic HMAC-SHA256 signing.
*
* @param {string} baseUrl - e.g. "https://api-test.ruby-gaming.com"
* @param {string} apiKey - Your team_api_key
* @param {string} apiSecret - Your team_api_secret
*/
constructor(baseUrl, apiKey, apiSecret) {
this.baseUrl = baseUrl.replace(/\/$/, "");
this.apiKey = apiKey;
this.apiSecret = apiSecret;
}
/**
* Compute the HMAC-SHA256 signature for a Team API request.
*
* Signature string: {timestamp}{METHOD}{path}{rawBody}
* - timestamp: Unix seconds as a decimal string
* - METHOD: Uppercase HTTP method (GET, POST, PUT, ...)
* - path: Request path including query string if present
* - rawBody: Raw request body string; empty string for no-body requests
*
* @param {string} method
* @param {string} path - Path including query string, e.g. "/api/bet/list?page=1&size=20"
* @param {string} body - Raw body string (default "")
* @returns {{ timestamp: number, signature: string }}
*/
_sign(method, path, body = "") {
const timestamp = Math.floor(Date.now() / 1000);
const message = `${timestamp}${method.toUpperCase()}${path}${body}`;
const signature = crypto
.createHmac("sha256", this.apiSecret)
.update(message, "utf8")
.digest("hex");
return { timestamp, signature };
}
/**
* Build the three required authentication headers plus Content-Type.
*
* @param {string} method
* @param {string} path
* @param {string} body
* @returns {object}
*/
_buildHeaders(method, path, body = "") {
const { timestamp, signature } = this._sign(method, path, body);
return {
"X-Team-Key": this.apiKey,
"X-Team-Timestamp": String(timestamp),
"X-Team-Signature": signature,
"Content-Type": "application/json",
};
}
/**
* Send an authenticated GET request.
*
* @param {string} path - e.g. "/api/bet/list"
* @param {object} params - Query parameters as a plain object
* @returns {Promise<object>} Parsed JSON response body
*/
async get(path, params = {}) {
const queryString = new URLSearchParams(params).toString();
const fullPath = queryString ? `${path}?${queryString}` : path;
const headers = this._buildHeaders("GET", fullPath, "");
const response = await axios.get(this.baseUrl + fullPath, { headers });
return response.data;
}
/**
* Send an authenticated POST request with a JSON body.
*
* @param {string} path
* @param {object} body
* @returns {Promise<object>} Parsed JSON response body
*/
async post(path, body) {
// Serialize once — the same string is used for signing and as the request body
const rawBody = JSON.stringify(body);
const headers = this._buildHeaders("POST", path, rawBody);
const response = await axios.post(this.baseUrl + path, rawBody, { headers });
return response.data;
}
/**
* Send an authenticated PUT request with a JSON body.
*
* @param {string} path
* @param {object} body
* @returns {Promise<object>} Parsed JSON response body
*/
async put(path, body) {
const rawBody = JSON.stringify(body);
const headers = this._buildHeaders("PUT", path, rawBody);
const response = await axios.put(this.baseUrl + path, rawBody, { headers });
return response.data;
}
}
module.exports = { RubyApiClient };
서명에 사용되는 본문은 HTTP 요청으로 전송되는 본문과 바이트 단위로 완전히 동일해야 합니다. 위 예제에서는 동일한 rawBody 문자열을 서명 함수와 axios 모두에 전달합니다. 서명과 전송 사이에 본문을 재포맷하거나 재직렬화하지 마십시오.
예제: 브랜드 생성
const { RubyApiClient } = require("./ruby-client");
const client = new RubyApiClient(
"https://api-test.ruby-gaming.com",
"your_team_api_key",
"your_team_api_secret"
);
async function createBrand() {
const data = await client.post("/api/brand/create", {
name: "My Brand",
code: "mybrand01",
wallet_mode: "seamless",
callback_url: "https://mybrand.example.com/ruby/callback",
currency: "KRW",
});
console.log(data);
// {
// id: 42,
// name: "My Brand",
// code: "mybrand01",
// api_key: "aBcDeFgH...",
// api_secret: "xYzSeCrEt...", <-- save this, returned only once
// wallet_mode: "seamless",
// status: 1,
// ...
// }
const { api_key, api_secret } = data;
// Store api_secret in your secret manager — it is never returned again
return data;
}
createBrand().catch(console.error);
api_secret을 즉시 저장하십시오api_secret 필드는 생성 응답에서만 한 번 반환됩니다. 다음 단계로 진행하기 전에 반드시 시크릿 매니저(Secret Manager)에 저장하십시오.
예제: 베팅 목록 조회
async function listBets(brandId) {
const data = await client.get("/api/bet/list", {
page: 1,
size: 20,
brand_id: brandId,
});
console.log(`Total bets: ${data.total}`);
data.items.forEach((bet) => console.log(bet));
return data;
}
listBets(42).catch(console.error);
콜백 서명 검증
브랜드에서 wallet_mode: "seamless"를 사용하는 경우, Ruby는 잔액 조회(Balance), 차감(Debit), 적립(Credit), 롤백(Rollback) 작업을 위해 귀하의 서버를 호출합니다. 각 요청은 HMAC-SHA256 서명이 포함되어 있으며, 요청을 처리하기 전에 반드시 이를 검증해야 합니다.
콜백 검증은 Team API와 다른 서명 알고리즘을 사용합니다. HMAC 입력값은 Buffer.concat([bodyBytes, Buffer.from(timestamp, "utf8")]) -- 본문이 먼저, 타임스탬프가 뒤에 옵니다. 키는 team_api_secret이 아닌 brand.api_secret입니다.
const crypto = require("crypto");
/**
* Verify the HMAC-SHA256 signature on an incoming Ruby callback request.
*
* @param {string} apiSecret - Your brand's api_secret
* @param {Buffer} bodyBytes - Raw request body as a Buffer (do NOT parse first)
* @param {string} timestamp - Value of the X-Aggregator-Timestamp header
* @param {string} signature - Value of the X-Aggregator-Signature header
* @param {number} maxAgeSeconds - Reject requests older than this (default 300)
* @returns {boolean}
*/
function verifyCallbackSignature(
apiSecret,
bodyBytes,
timestamp,
signature,
maxAgeSeconds = 300
) {
// Validate timestamp freshness
const ts = parseInt(timestamp, 10);
if (isNaN(ts)) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - ts) > maxAgeSeconds) return false;
// Callback signing: HMAC-SHA256(key=apiSecret, data=body_bytes + timestamp_bytes)
const hmacInput = Buffer.concat([bodyBytes, Buffer.from(timestamp, "utf8")]);
const expected = crypto
.createHmac("sha256", apiSecret)
.update(hmacInput)
.digest("hex");
// Constant-time comparison to prevent timing attacks
if (expected.length !== signature.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected, "utf8"),
Buffer.from(signature, "utf8")
);
}
module.exports = { verifyCallbackSignature };
Express 미들웨어 예제
const express = require("express");
const { verifyCallbackSignature } = require("./verify-callback");
const app = express();
const BRAND_API_KEY = "key_brandabc"; // From create brand response
const BRAND_API_SECRET = "my_brand_secret"; // From create brand response
// IMPORTANT: Use raw body middleware so we capture bytes before JSON parsing
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf; // Store raw Buffer on the request object
},
})
);
/**
* Shared signature verification middleware for all callback routes.
*/
function verifySignature(req, res, next) {
const valid = verifyCallbackSignature(
BRAND_API_SECRET,
req.rawBody,
req.headers["x-aggregator-timestamp"] ?? "",
req.headers["x-aggregator-signature"] ?? ""
);
if (!valid) {
return res.status(401).json({ error: "Invalid signature" });
}
// Verify the API key matches your brand
if (req.headers["x-aggregator-key"] !== BRAND_API_KEY) {
return res.status(401).json({ error: "Unknown API key" });
}
next();
}
const router = express.Router();
/** Balance inquiry — return current player balance. */
router.post("/callback/balance", verifySignature, (req, res) => {
return res.json({ balance: "1250.00" });
});
/** Debit — deduct from player wallet (must be idempotent on transaction_id). */
router.post("/callback/debit", verifySignature, (req, res) => {
return res.json({ balance: "1149.50", balance_before: "1250.00" });
});
/** Credit — add winnings to player wallet (must be idempotent on transaction_id). */
router.post("/callback/credit", verifySignature, (req, res) => {
return res.json({ balance: "1350.00", balance_before: "1250.00" });
});
/** Rollback — reverse a previous debit (must be idempotent on transaction_id). */
router.post("/callback/rollback", verifySignature, (req, res) => {
return res.json({ balance: "1250.00" });
});
app.use(router);
app.listen(3000);
자주 발생하는 실수
| 실수 | 결과 | 해결 방법 |
|---|---|---|
| 서명 시 경로에서 쿼리 문자열 누락 | 서명 불일치 (401) | GET 요청 서명 시 경로에 항상 ?key=value를 포함하십시오 |
| 서명 후 JSON 본문 재직렬화 | 서명 불일치 (401) | 동일한 문자열을 서명 함수와 HTTP 클라이언트 모두에 전달하십시오 |
| 만료된 타임스탬프 (300초 이상 차이) | 요청 거부 (401) | 시스템 시계를 NTP와 동기화하십시오 |
콜백 검증에 team_api_secret 사용 | 검증 실패 | 콜백은 team_api_secret이 아닌 brand.api_secret을 사용합니다 |
verify 훅 없이 express.json() 사용 | 원시 바이트 읽기 불가 | verify 콜백을 추가하여 파싱 전에 req.rawBody를 캡처하십시오 |
timingSafeEqual 대신 문자열 동등 비교 사용 | 타이밍 공격(Timing Attack) 취약점 | 서명 비교 시 항상 crypto.timingSafeEqual을 사용하십시오 |