← Plugin SDK

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 in X-License-Timestamp.
  • nonce — same value sent in X-License-Nonce.
  • METHOD — HTTP method, upper-case. The License API only accepts POST.
  • path — request URL path without query string, e.g. /api/v1/license/activate.
  • bodyHashhex(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.

#CheckError code (numeric)HTTP
1Any required header missingBAD_SIGNATURE (1700)401
2Timestamp not parseable or not finiteBAD_SIGNATURE (1700)401
3`now − timestamp> 300` seconds
4Nonce not UUID v4 and not 32 hex charsBAD_SIGNATURE (1700)401
5Nonce already seen within 300-second TTL window (replay)BAD_SIGNATURE (1700)401
6HMAC mismatchBAD_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.