HMAC Signing
Sign every request to the License API with an HMAC-SHA256 signature, a fresh nonce, and a current timestamp.
Every request to /api/v1/license/* must carry three headers:
X-License-Timestamp: <unix seconds, base-10 integer>
X-License-Nonce: <UUID v4 OR 16-byte random hex (32 chars)>
X-License-Signature: <hex(HMAC-SHA256(secret, signing-input))>Signing input
Build a UTF-8 byte string from five fields joined by ASCII colons (:):
<timestamp>:<nonce>:<METHOD>:<path>:<bodyHash>timestamp— same value sent inX-License-Timestamp.nonce— same value sent inX-License-Nonce.METHOD— HTTP method, upper-case. The License API only acceptsPOST.path— request URL path without query string, e.g./api/v1/license/activate.bodyHash—hex(SHA-256(rawBody)). For an empty body, hash the empty string (e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855).
Compute the signature as HMAC-SHA256(secret, signingInput) and send it as lower-case hex.
Shared secret
The shared secret is provisioned per plugin build. Never embed it in source you ship to end users — the Unity plugin keeps it in a build-time constant compiled into a stripped IL assembly.
Rotating the secret invalidates every shipped plugin build that knows the old value. Rotate only when releasing a new plugin version that ships the new secret. Per-machine derived secrets are deferred to protocol v2.
Rejection rules
The server applies these checks in order. The first match wins.
| # | Check | Error code (numeric) | HTTP |
|---|---|---|---|
| 1 | Any required header missing | BAD_SIGNATURE (1700) | 401 |
| 2 | Timestamp not parseable or not finite | BAD_SIGNATURE (1700) | 401 |
| 3 | ` | now − timestamp | > 300` seconds |
| 4 | Nonce not UUID v4 and not 32 hex chars | BAD_SIGNATURE (1700) | 401 |
| 5 | Nonce already seen within 300-second TTL window (replay) | BAD_SIGNATURE (1700) | 401 |
| 6 | HMAC mismatch | BAD_SIGNATURE (1700) | 401 |
The nonce is recorded only on accept. A request that fails for any other reason does not burn its nonce — clients can safely retry with the same nonce after fixing the underlying error, as long as the timestamp is still in window.
Worked example (Node.js)
import crypto from "node:crypto";
const secret = process.env.LICENSE_API_SECRET!;
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = crypto.randomUUID();
const method = "POST";
const path = "/api/v1/license/activate";
const body = JSON.stringify({
licenseKey: "11111111-2222-3333-4444-555555555555",
machineId: "abc12345-deadbeef",
});
const bodyHash = crypto.createHash("sha256").update(body, "utf8").digest("hex");
const signingInput = `${timestamp}:${nonce}:${method}:${path}:${bodyHash}`;
const signature = crypto.createHmac("sha256", secret).update(signingInput).digest("hex");
await fetch(`https://ntkfoundry.com${path}`, {
method,
headers: {
"Content-Type": "application/json",
"X-License-Timestamp": timestamp,
"X-License-Nonce": nonce,
"X-License-Signature": signature,
},
body,
});Worked example (C# / .NET)
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
var secret = Environment.GetEnvironmentVariable("LICENSE_API_SECRET")!;
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var nonce = Guid.NewGuid().ToString();
var method = "POST";
var path = "/api/v1/license/activate";
var bodyJson = "{\"licenseKey\":\"...\",\"machineId\":\"...\"}";
var bodyBytes = Encoding.UTF8.GetBytes(bodyJson);
var bodyHash = Convert.ToHexString(SHA256.HashData(bodyBytes)).ToLowerInvariant();
var signingInput = $"{timestamp}:{nonce}:{method}:{path}:{bodyHash}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var signature = Convert.ToHexString(
hmac.ComputeHash(Encoding.UTF8.GetBytes(signingInput))
).ToLowerInvariant();
using var http = new HttpClient { BaseAddress = new Uri("https://ntkfoundry.com") };
var req = new HttpRequestMessage(HttpMethod.Post, path) {
Content = new ByteArrayContent(bodyBytes) {
Headers = { { "Content-Type", "application/json" } },
},
};
req.Headers.Add("X-License-Timestamp", timestamp);
req.Headers.Add("X-License-Nonce", nonce);
req.Headers.Add("X-License-Signature", signature);
var resp = await http.SendAsync(req);Both languages produce the same signature byte-for-byte when given the same secret, timestamp, nonce, method, path, and body. If your client produces a different signature, double-check that:
- the body bytes you hash are the exact bytes you send (no whitespace stripping, no re-serialisation),
- the path you sign does not include the query string,
- the method is upper-case,
- the signature is lower-case hex.