Skip to main content

Java SDK Examples

This page provides a complete Java integration example for the Ruby Team API, including the HMAC-SHA256 signing utility, an HTTP client wrapper using the built-in java.net.http.HttpClient (Java 11+), and a callback verification utility for Seamless Wallet integrations.

Requirements: Java 11 or later (uses java.net.http.HttpClient and HexFormat from Java 17; see note below for Java 11 compatibility)

Java version compatibility

HexFormat was introduced in Java 17. If you are on Java 11–16, replace HexFormat.of().formatHex(raw) with a manual hex conversion:

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

Signing Utility and HTTP Client

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();
}
}
Body serialization

The body string passed to post() / put() must be byte-for-byte identical to the body sent in the HTTP request. Serialize your JSON exactly once, then pass the same string to both the client method and your JSON parser. Do not reformat or re-serialize between signing and sending.


Example: Create a Brand

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,
// ...
// }
}
}
warning
Save api_secret immediately

Parse the response JSON and extract api_secret immediately. Store it in your secret manager — it is never returned again in subsequent calls.


Example: Query Bet List

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": [...]}
}
}

Callback Signature Verification

If your brand uses wallet_mode: "seamless", Ruby will call your server for balance, debit, credit, and rollback operations. Each request is signed with an HMAC-SHA256 signature. You must verify it before processing the request.

Different algorithm

Callback verification uses a different signing algorithm from the Team API. The HMAC input is body_bytes + timestamp_bytes — body first, timestamp at the end. The key is your brand.api_secret, not your team_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 Controller Example

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"));
}
}

Common Mistakes

MistakeResultFix
Query string missing from path during signingSignature mismatch (401)Always include ?key=value in the path string passed to sign()
Re-serializing JSON body after signingSignature mismatch (401)Serialize once and pass the same String to both sign() and the HTTP publisher
Stale timestamp (>300 s drift)Request rejected (401)Sync JVM clock with NTP; use Instant.now().getEpochSecond()
Using team_api_secret for callback verificationVerification failsCallbackVerifier must be initialized with brand.api_secret, not team_api_secret
Parsing body before calling verify()Signature mismatchRead @RequestBody byte[] — keep raw bytes until after verification
String.equals() for signature comparisonTiming attack vulnerabilityAlways use MessageDigest.isEqual() for constant-time comparison