Callback Authentication
Every callback request sent by Ruby to your server includes three authentication headers. You must verify these headers on every incoming request before processing it.
Headers Sent by Ruby
X-Aggregator-Key: {brand.api_key}
X-Aggregator-Timestamp: {unix_timestamp_seconds}
X-Aggregator-Signature: {hmac_sha256_hex_digest}
| Header | Value | Description |
|---|---|---|
X-Aggregator-Key | String | Your brand's API key, as configured in Ruby |
X-Aggregator-Timestamp | String (integer) | Unix timestamp in seconds when the request was signed |
X-Aggregator-Signature | String (hex) | HMAC-SHA256 hex digest authenticating the request |
Signing Algorithm
Ruby computes the signature as follows:
HMAC-SHA256(
key: api_secret encoded as UTF-8 bytes,
data: raw_body_bytes + str(timestamp).encode("utf-8")
)
In plain terms:
- Take the raw HTTP request body as bytes (do not parse or re-serialize)
- Take the timestamp string from
X-Aggregator-Timestampand encode it as UTF-8 bytes - Concatenate:
body_bytes + timestamp_bytes(body first, timestamp appended at the end) - Compute HMAC-SHA256 using your
api_secretas the key - The signature is the lowercase hex digest of the result
The body bytes used for signing are the exact bytes received over the wire. Never JSON-parse and re-serialize the body before computing the signature — any change in field order, whitespace, or encoding will produce a different digest and cause verification to fail.
Comparison with Team API Signing
The callback signing algorithm is intentionally different from the Team API signing algorithm used when you call Ruby's endpoints. Do not confuse the two.
| Aspect | Team API (you call Ruby) | Callback (Ruby calls you) |
|---|---|---|
| HMAC key | team_api_secret | brand.api_secret |
| Input format | String: {ts}{METHOD}{path}{body} | Bytes: body_bytes + ts_string_bytes |
| Timestamp position | First | Last |
| Includes HTTP method | Yes | No |
| Includes URL path | Yes | No |
| Header prefix | X-Team-* | X-Aggregator-* |
Verification Steps
Follow these seven steps in order to verify an incoming callback request:
Step 1 — Read the raw body bytes
Capture the raw HTTP request body before any parsing. Store these exact bytes for later use in signature computation.
Step 2 — Extract headers
Read the three headers: X-Aggregator-Key, X-Aggregator-Timestamp, X-Aggregator-Signature.
Step 3 — Validate the API key
Check that X-Aggregator-Key matches the api_key you have on file for your Ruby brand configuration. Reject with 401 if it does not match.
Step 4 — Validate the timestamp
Parse X-Aggregator-Timestamp as an integer. Compute the absolute difference between that timestamp and your current server time (Unix seconds). If the difference is greater than 300 seconds (5 minutes), reject the request with 401.
This protects against replay attacks. Ensure your server clock is synchronized (NTP is recommended).
Step 5 — Compute the expected signature
data = raw_body_bytes + str(timestamp).encode("utf-8")
expected_sig = HMAC-SHA256(api_secret.encode("utf-8"), data).hexdigest()
Step 6 — Compare signatures
Compare expected_sig with the value from X-Aggregator-Signature using a constant-time comparison function. Do not use a regular string equality check — this prevents timing attacks.
Step 7 — Parse the body
Only after the signature is verified, proceed to JSON-parse the request body and handle the business logic.
Worked Example
Incoming request:
POST /ruby/debit
X-Aggregator-Key: key_brandabc
X-Aggregator-Timestamp: 1711500000
X-Aggregator-Signature: <see below>
Content-Type: application/json
{"player_id": 42, "amount": "100.50", "transaction_id": "txn_abc"}
Computing the expected signature:
api_secret = "my_brand_secret"
raw_body_bytes = b'{"player_id": 42, "amount": "100.50", "transaction_id": "txn_abc"}'
timestamp_bytes = b'1711500000'
hmac_input = raw_body_bytes + timestamp_bytes
= b'{"player_id": 42, "amount": "100.50", "transaction_id": "txn_abc"}1711500000'
signature = HMAC-SHA256(key=b"my_brand_secret", data=hmac_input).hexdigest()
The resulting hex digest is what you compare against X-Aggregator-Signature.
Code Examples
Python
import hashlib
import hmac
import time
from typing import Optional
def verify_callback_signature(
raw_body: bytes,
api_key_header: str,
timestamp_header: str,
signature_header: str,
expected_api_key: str,
api_secret: str,
max_age_seconds: int = 300,
) -> bool:
"""
Verify the HMAC-SHA256 signature on an incoming Ruby callback request.
Returns True if the request is authentic, False otherwise.
"""
# Step 3: Validate API key
if api_key_header != expected_api_key:
return False
# Step 4: Validate timestamp
try:
timestamp = int(timestamp_header)
except ValueError:
return False
if abs(int(time.time()) - timestamp) > max_age_seconds:
return False
# Step 5: Compute expected signature
hmac_input = raw_body + timestamp_header.encode("utf-8")
expected_sig = hmac.new(
api_secret.encode("utf-8"),
hmac_input,
hashlib.sha256,
).hexdigest()
# Step 6: Constant-time comparison
return hmac.compare_digest(expected_sig, signature_header)
# Flask example
from flask import Flask, request, abort
app = Flask(__name__)
API_KEY = "key_brandabc"
API_SECRET = "my_brand_secret"
@app.route("/ruby/debit", methods=["POST"])
def handle_debit():
raw_body = request.get_data() # raw bytes, before any parsing
valid = verify_callback_signature(
raw_body=raw_body,
api_key_header=request.headers.get("X-Aggregator-Key", ""),
timestamp_header=request.headers.get("X-Aggregator-Timestamp", ""),
signature_header=request.headers.get("X-Aggregator-Signature", ""),
expected_api_key=API_KEY,
api_secret=API_SECRET,
)
if not valid:
abort(401)
data = request.get_json()
# ... process debit ...
return {"balance": "1149.50", "balance_before": "1250.00"}
Node.js
const crypto = require("crypto");
/**
* Verify the HMAC-SHA256 signature on an incoming Ruby callback request.
*
* @param {Buffer} rawBody - Raw request body bytes (before any parsing)
* @param {string} apiKeyHeader
* @param {string} timestampHeader
* @param {string} signatureHeader
* @param {string} expectedApiKey
* @param {string} apiSecret
* @param {number} maxAgeSeconds - Default 300 (5 minutes)
* @returns {boolean}
*/
function verifyCallbackSignature(
rawBody,
apiKeyHeader,
timestampHeader,
signatureHeader,
expectedApiKey,
apiSecret,
maxAgeSeconds = 300
) {
// Step 3: Validate API key
if (apiKeyHeader !== expectedApiKey) return false;
// Step 4: Validate timestamp
const timestamp = parseInt(timestampHeader, 10);
if (isNaN(timestamp)) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > maxAgeSeconds) return false;
// Step 5: Compute expected signature
const hmacInput = Buffer.concat([
rawBody,
Buffer.from(timestampHeader, "utf8"),
]);
const expectedSig = crypto
.createHmac("sha256", apiSecret)
.update(hmacInput)
.digest("hex");
// Step 6: Constant-time comparison
if (expectedSig.length !== signatureHeader.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expectedSig, "utf8"),
Buffer.from(signatureHeader, "utf8")
);
}
// Express example
const express = require("express");
const app = express();
const API_KEY = "key_brandabc";
const API_SECRET = "my_brand_secret";
// IMPORTANT: Use raw body middleware so we can read bytes before parsing
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf;
},
})
);
app.post("/ruby/debit", (req, res) => {
const valid = verifyCallbackSignature(
req.rawBody,
req.headers["x-aggregator-key"] ?? "",
req.headers["x-aggregator-timestamp"] ?? "",
req.headers["x-aggregator-signature"] ?? "",
API_KEY,
API_SECRET
);
if (!valid) {
return res.status(401).json({ error: "Invalid signature" });
}
const { player_id, amount, transaction_id } = req.body;
// ... process debit ...
res.json({ balance: "1149.50", balance_before: "1250.00" });
});
Java
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;
public class CallbackAuthVerifier {
private final String expectedApiKey;
private final String apiSecret;
private final int maxAgeSeconds;
public CallbackAuthVerifier(String expectedApiKey, String apiSecret) {
this(expectedApiKey, apiSecret, 300);
}
public CallbackAuthVerifier(String expectedApiKey, String apiSecret, int maxAgeSeconds) {
this.expectedApiKey = expectedApiKey;
this.apiSecret = apiSecret;
this.maxAgeSeconds = maxAgeSeconds;
}
/**
* Verify the HMAC-SHA256 signature on an incoming Ruby callback request.
*
* @param rawBody Raw request body bytes (do NOT parse before calling this)
* @param apiKeyHeader Value of X-Aggregator-Key header
* @param timestampHeader Value of X-Aggregator-Timestamp header
* @param signatureHeader Value of X-Aggregator-Signature header
* @return true if the request is authentic
*/
public boolean verify(
byte[] rawBody,
String apiKeyHeader,
String timestampHeader,
String signatureHeader
) {
// Step 3: Validate API key
if (!expectedApiKey.equals(apiKeyHeader)) {
return false;
}
// Step 4: Validate timestamp
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;
}
// Step 5: Compute expected signature
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"));
byte[] digest = mac.doFinal(hmacInput);
String expectedSig = HexFormat.of().formatHex(digest);
// Step 6: Constant-time comparison
return MessageDigest.isEqual(
expectedSig.getBytes(StandardCharsets.UTF_8),
signatureHeader.getBytes(StandardCharsets.UTF_8)
);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new RuntimeException("HMAC-SHA256 not available", e);
}
}
}
// Spring Boot controller example
// @RestController
// public class WalletCallbackController {
//
// private final CallbackAuthVerifier verifier =
// new CallbackAuthVerifier("key_brandabc", "my_brand_secret");
//
// @PostMapping("/ruby/debit")
// public ResponseEntity<?> handleDebit(
// HttpServletRequest httpRequest,
// @RequestHeader("X-Aggregator-Key") String apiKey,
// @RequestHeader("X-Aggregator-Timestamp") String timestamp,
// @RequestHeader("X-Aggregator-Signature") String signature,
// @RequestBody byte[] rawBody // Use byte[] to get raw bytes
// ) {
// if (!verifier.verify(rawBody, apiKey, timestamp, signature)) {
// return ResponseEntity.status(401).body("Invalid signature");
// }
// // Parse and process ...
// return ResponseEntity.ok(Map.of("balance", "1149.50", "balance_before", "1250.00"));
// }
// }
Common Mistakes
Re-serializing the body before signing
The signature is computed over the raw bytes received on the wire. If you JSON-parse the body and then re-serialize it before computing the HMAC, field ordering or whitespace may differ, causing a mismatch.
Always capture the raw body bytes before any JSON parsing.
Using the wrong secret
Callback verification uses your brand.api_secret, not the team_api_secret used to call Ruby's Team API. These are different credentials.
Incorrect concatenation order
The HMAC input is body_bytes + timestamp_bytes — body first, timestamp appended at the end. The Team API signing puts the timestamp first. Do not confuse the two.
String comparison instead of constant-time comparison
Use hmac.compare_digest (Python), crypto.timingSafeEqual (Node.js), or MessageDigest.isEqual (Java) for the final comparison. A plain == check is vulnerable to timing attacks.
Missing timestamp validation
Always check that the timestamp is within 300 seconds of your current server time. Without this check, a valid signature could be replayed indefinitely.