본문으로 건너뛰기
한국어 번역 진행 중한국어 문서를 완성하는 동안 일부 가이드와 API 설명은 아직 영어로 표시될 수 있습니다.

Java SDK 예제

이 페이지에서는 Ruby Team API를 위한 완전한 Java 연동 예제를 제공합니다. HMAC-SHA256 서명 유틸리티, 내장 java.net.http.HttpClient (Java 11+)를 사용한 HTTP 클라이언트 래퍼, 그리고 Seamless Wallet 연동을 위한 콜백(Callback) 검증 유틸리티를 포함합니다.

요구 사항: Java 11 이상 (java.net.http.HttpClient 및 Java 17의 HexFormat 사용; Java 11 호환성에 대해서는 아래 참고사항을 확인하십시오)

Java 버전 호환성

HexFormat은 Java 17에서 도입되었습니다. Java 11-16을 사용하는 경우, HexFormat.of().formatHex(raw)를 수동 16진수 변환으로 대체하십시오:

StringBuilder sb = new StringBuilder();
for (byte b : raw) sb.append(String.format("%02x", b));
return sb.toString();

서명 유틸리티 및 HTTP 클라이언트

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Map;
import java.util.StringJoiner;

/**
* HTTP client for the Ruby Team API with automatic HMAC-SHA256 signing.
*
* <p>Thread-safe: a single instance can be shared across multiple threads.
*/
public class RubyApiClient {

private final String baseUrl;
private final String apiKey;
private final String apiSecret;
private final HttpClient httpClient;

/**
* @param baseUrl e.g. "https://api-test.ruby-gaming.com"
* @param apiKey Your team_api_key
* @param apiSecret Your team_api_secret
*/
public RubyApiClient(String baseUrl, String apiKey, String apiSecret) {
this.baseUrl = baseUrl.replaceAll("/$", "");
this.apiKey = apiKey;
this.apiSecret = apiSecret;
this.httpClient = HttpClient.newHttpClient();
}

/**
* Compute the HMAC-SHA256 signature for a Team API request.
*
* <p>Signature string: {@code {timestamp}{METHOD}{path}{rawBody}}
* <ul>
* <li>timestamp — Unix seconds as a decimal string</li>
* <li>METHOD — Uppercase HTTP method (GET, POST, PUT, …)</li>
* <li>path — Request path including query string if present</li>
* <li>rawBody — Raw body string; empty string for no-body requests</li>
* </ul>
*
* @param timestamp Unix epoch seconds
* @param method HTTP method (case-insensitive)
* @param path Path with optional query string, e.g. "/api/bet/list?page=1&size=20"
* @param body Raw body string, or {@code ""} for GET requests
* @return Lowercase hex-encoded HMAC-SHA256 digest
*/
public String sign(long timestamp, String method, String path, String body)
throws NoSuchAlgorithmException, InvalidKeyException {
String message = timestamp + method.toUpperCase() + path + (body != null ? body : "");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] raw = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(raw);
}

/**
* Build a query string from a map of parameters, e.g. {@code "page=1&size=20"}.
*/
private String buildQueryString(Map<String, String> params) {
if (params == null || params.isEmpty()) return "";
StringJoiner joiner = new StringJoiner("&");
for (Map.Entry<String, String> entry : params.entrySet()) {
joiner.add(
URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)
+ "="
+ URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)
);
}
return joiner.toString();
}

/**
* Send an authenticated GET request.
*
* @param path e.g. "/api/bet/list"
* @param params Query parameters (may be null or empty)
* @return Raw HTTP response body as a String
*/
public String get(String path, Map<String, String> params) throws Exception {
String queryString = buildQueryString(params);
String fullPath = queryString.isEmpty() ? path : path + "?" + queryString;
long timestamp = Instant.now().getEpochSecond();
String signature = sign(timestamp, "GET", fullPath, "");

HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + fullPath))
.header("X-Team-Key", apiKey)
.header("X-Team-Timestamp", String.valueOf(timestamp))
.header("X-Team-Signature", signature)
.header("Content-Type", "application/json")
.GET()
.build();

return httpClient.send(request, HttpResponse.BodyHandlers.ofString()).body();
}

/**
* Send an authenticated POST request with a JSON body.
*
* @param path e.g. "/api/brand/create"
* @param rawBody JSON-serialized body string — must be identical to the bytes sent
* @return Raw HTTP response body as a String
*/
public String post(String path, String rawBody) throws Exception {
long timestamp = Instant.now().getEpochSecond();
String signature = sign(timestamp, "POST", path, rawBody);

HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + path))
.header("X-Team-Key", apiKey)
.header("X-Team-Timestamp", String.valueOf(timestamp))
.header("X-Team-Signature", signature)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(rawBody, StandardCharsets.UTF_8))
.build();

return httpClient.send(request, HttpResponse.BodyHandlers.ofString()).body();
}

/**
* Send an authenticated PUT request with a JSON body.
*
* @param path e.g. "/api/brand/42"
* @param rawBody JSON-serialized body string — must be identical to the bytes sent
* @return Raw HTTP response body as a String
*/
public String put(String path, String rawBody) throws Exception {
long timestamp = Instant.now().getEpochSecond();
String signature = sign(timestamp, "PUT", path, rawBody);

HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + path))
.header("X-Team-Key", apiKey)
.header("X-Team-Timestamp", String.valueOf(timestamp))
.header("X-Team-Signature", signature)
.header("Content-Type", "application/json")
.PUT(HttpRequest.BodyPublishers.ofString(rawBody, StandardCharsets.UTF_8))
.build();

return httpClient.send(request, HttpResponse.BodyHandlers.ofString()).body();
}
}
본문 직렬화

post() / put()에 전달하는 본문 문자열은 HTTP 요청으로 전송되는 본문과 바이트 단위로 완전히 동일해야 합니다. JSON을 정확히 한 번만 직렬화한 후, 동일한 문자열을 클라이언트 메서드와 JSON 파서 모두에 전달하십시오. 서명과 전송 사이에 재포맷하거나 재직렬화하지 마십시오.


예제: 브랜드 생성

public class CreateBrandExample {

public static void main(String[] args) throws Exception {
RubyApiClient client = new RubyApiClient(
"https://api-test.ruby-gaming.com",
"your_team_api_key",
"your_team_api_secret"
);

// Build the request body — serialize once, use for both signing and sending
String body = "{\"name\":\"My Brand\","
+ "\"code\":\"mybrand01\","
+ "\"wallet_mode\":\"seamless\","
+ "\"callback_url\":\"https://mybrand.example.com/ruby/callback\","
+ "\"currency\":\"KRW\"}";

String responseBody = client.post("/api/brand/create", body);
System.out.println(responseBody);
// {
// "id": 42,
// "name": "My Brand",
// "code": "mybrand01",
// "api_key": "aBcDeFgH...",
// "api_secret": "xYzSeCrEt...", <-- save this, returned only once
// "wallet_mode": "seamless",
// "status": 1,
// ...
// }
}
}
경고
api_secret을 즉시 저장하십시오

응답 JSON을 파싱하여 api_secret을 즉시 추출하십시오. 시크릿 매니저(Secret Manager)에 저장하십시오 -- 이후 호출에서는 다시 반환되지 않습니다.


예제: 베팅 목록 조회

import java.util.LinkedHashMap;
import java.util.Map;

public class ListBetsExample {

public static void main(String[] args) throws Exception {
RubyApiClient client = new RubyApiClient(
"https://api-test.ruby-gaming.com",
"your_team_api_key",
"your_team_api_secret"
);

Map<String, String> params = new LinkedHashMap<>();
params.put("page", "1");
params.put("size", "20");
params.put("brand_id", "42");

String responseBody = client.get("/api/bet/list", params);
System.out.println(responseBody);
// {"total": 153, "page": 1, "page_size": 20, "items": [...]}
}
}

콜백 서명 검증

브랜드에서 wallet_mode: "seamless"를 사용하는 경우, Ruby는 잔액 조회(Balance), 차감(Debit), 적립(Credit), 롤백(Rollback) 작업을 위해 귀하의 서버를 호출합니다. 각 요청은 HMAC-SHA256 서명이 포함되어 있으며, 요청을 처리하기 전에 반드시 이를 검증해야 합니다.

다른 알고리즘

콜백 검증은 Team API와 다른 서명 알고리즘을 사용합니다. HMAC 입력값은 body_bytes + timestamp_bytes -- 본문이 먼저, 타임스탬프가 뒤에 옵니다. 키는 team_api_secret이 아닌 brand.api_secret입니다.

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HexFormat;

/**
* Verifies HMAC-SHA256 signatures on incoming Ruby Seamless Wallet callback requests.
*
* <p>Thread-safe: a single instance can be shared across multiple threads.
*/
public class CallbackVerifier {

private final String apiSecret;
private final int maxAgeSeconds;

/**
* @param apiSecret Your brand's api_secret (from the create brand response)
* @param maxAgeSeconds Reject requests older than this many seconds (recommended: 300)
*/
public CallbackVerifier(String apiSecret, int maxAgeSeconds) {
this.apiSecret = apiSecret;
this.maxAgeSeconds = maxAgeSeconds;
}

public CallbackVerifier(String apiSecret) {
this(apiSecret, 300);
}

/**
* Verify the signature on an incoming Ruby callback request.
*
* <p>Call this method with the raw body bytes received over the wire, before any
* JSON parsing. If this method returns {@code false}, reject the request with HTTP 401.
*
* @param rawBody Raw HTTP request body bytes — do NOT parse before calling this
* @param timestampHeader Value of the {@code X-Aggregator-Timestamp} header
* @param signatureHeader Value of the {@code X-Aggregator-Signature} header
* @return {@code true} if the signature is valid and the request is fresh
*/
public boolean verify(byte[] rawBody, String timestampHeader, String signatureHeader) {
// Validate timestamp freshness
long timestamp;
try {
timestamp = Long.parseLong(timestampHeader);
} catch (NumberFormatException e) {
return false;
}

long now = Instant.now().getEpochSecond();
if (Math.abs(now - timestamp) > maxAgeSeconds) {
return false;
}

// Callback signing: HMAC-SHA256(key=apiSecret, data=body_bytes + timestamp_bytes)
try {
byte[] timestampBytes = timestampHeader.getBytes(StandardCharsets.UTF_8);
byte[] hmacInput = new byte[rawBody.length + timestampBytes.length];
System.arraycopy(rawBody, 0, hmacInput, 0, rawBody.length);
System.arraycopy(timestampBytes, 0, hmacInput, rawBody.length, timestampBytes.length);

Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = HexFormat.of().formatHex(mac.doFinal(hmacInput));

// Constant-time comparison to prevent timing attacks
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
signatureHeader.getBytes(StandardCharsets.UTF_8)
);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new RuntimeException("HmacSHA256 not available", e);
}
}
}

Spring Boot 컨트롤러 예제

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/callback")
public class WalletCallbackController {

private static final String BRAND_API_KEY = "key_brandabc"; // From create brand response
private static final String BRAND_API_SECRET = "my_brand_secret"; // From create brand response

private final CallbackVerifier verifier = new CallbackVerifier(BRAND_API_SECRET);

/**
* Verify the API key and HMAC-SHA256 signature.
* Returns {@code false} if either check fails — caller should return 401.
*/
private boolean verifyRequest(String apiKeyHeader, String timestampHeader,
String signatureHeader, byte[] rawBody) {
if (!BRAND_API_KEY.equals(apiKeyHeader)) return false;
return verifier.verify(rawBody, timestampHeader, signatureHeader);
}

/** Balance inquiry — return current player balance. */
@PostMapping("/balance")
public ResponseEntity<?> handleBalance(
@RequestHeader("X-Aggregator-Key") String apiKeyHeader,
@RequestHeader("X-Aggregator-Timestamp") String timestampHeader,
@RequestHeader("X-Aggregator-Signature") String signatureHeader,
@RequestBody byte[] rawBody
) {
if (!verifyRequest(apiKeyHeader, timestampHeader, signatureHeader, rawBody)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
return ResponseEntity.ok(Map.of("balance", "1250.00"));
}

/** Debit — deduct from player wallet (must be idempotent on transaction_id). */
@PostMapping("/debit")
public ResponseEntity<?> handleDebit(
@RequestHeader("X-Aggregator-Key") String apiKeyHeader,
@RequestHeader("X-Aggregator-Timestamp") String timestampHeader,
@RequestHeader("X-Aggregator-Signature") String signatureHeader,
@RequestBody byte[] rawBody
) {
if (!verifyRequest(apiKeyHeader, timestampHeader, signatureHeader, rawBody)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
return ResponseEntity.ok(Map.of("balance", "1149.50", "balance_before", "1250.00"));
}

/** Credit — add winnings to player wallet (must be idempotent on transaction_id). */
@PostMapping("/credit")
public ResponseEntity<?> handleCredit(
@RequestHeader("X-Aggregator-Key") String apiKeyHeader,
@RequestHeader("X-Aggregator-Timestamp") String timestampHeader,
@RequestHeader("X-Aggregator-Signature") String signatureHeader,
@RequestBody byte[] rawBody
) {
if (!verifyRequest(apiKeyHeader, timestampHeader, signatureHeader, rawBody)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
return ResponseEntity.ok(Map.of("balance", "1350.00", "balance_before", "1250.00"));
}

/** Rollback — reverse a previous debit (must be idempotent on transaction_id). */
@PostMapping("/rollback")
public ResponseEntity<?> handleRollback(
@RequestHeader("X-Aggregator-Key") String apiKeyHeader,
@RequestHeader("X-Aggregator-Timestamp") String timestampHeader,
@RequestHeader("X-Aggregator-Signature") String signatureHeader,
@RequestBody byte[] rawBody
) {
if (!verifyRequest(apiKeyHeader, timestampHeader, signatureHeader, rawBody)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
return ResponseEntity.ok(Map.of("balance", "1250.00"));
}
}

자주 발생하는 실수

실수결과해결 방법
서명 시 경로에서 쿼리 문자열 누락서명 불일치 (401)sign()에 전달하는 경로 문자열에 항상 ?key=value를 포함하십시오
서명 후 JSON 본문 재직렬화서명 불일치 (401)한 번만 직렬화하고 동일한 Stringsign()과 HTTP publisher 모두에 전달하십시오
만료된 타임스탬프 (300초 이상 차이)요청 거부 (401)JVM 시계를 NTP와 동기화하십시오; Instant.now().getEpochSecond()를 사용하십시오
콜백 검증에 team_api_secret 사용검증 실패CallbackVerifierteam_api_secret이 아닌 brand.api_secret으로 초기화해야 합니다
verify() 호출 전에 본문 파싱서명 불일치@RequestBody byte[]로 읽으십시오 -- 검증이 완료될 때까지 원시 바이트를 유지하십시오
서명 비교에 String.equals() 사용타이밍 공격(Timing Attack) 취약점상수 시간 비교를 위해 항상 MessageDigest.isEqual()을 사용하십시오