Validation API
One endpoint, one JSON request, one signed JSON response. This is the whole contract — the SDKs are just convenience wrappers over it. It is transport-only: nothing here is specific to a language or runtime.
Endpoint
POST https://licenses.rymga.com/t/{slug}/api/v1/validate
Content-Type: application/json{slug} is your tenant slug, from the panel. All traffic is HTTPS.
Request
{
"licenseKey": "RYMGA-XXXX-XXXX-XXXX",
"productCode": "YOUR_PRODUCT",
"serverId": "stable-machine-fingerprint",
"label": "optional human-readable label",
"nonce": "unique-per-request-uuid",
"timestamp": 1733600000000
}| Field | Required | Notes |
|---|---|---|
| licenseKey | yes | The key issued to your customer. |
| productCode | yes | Must match the product the license belongs to. |
| serverId | yes | Stable fingerprint of the machine — drives the server cap. |
| label | no | Free label shown next to the activation in your panel. |
| nonce | yes | Unique per request (a UUID). Replays are rejected within a short window. |
| timestamp | yes | Epoch milliseconds. |
The serverId
The serverId is any string that is stable for one machine and
reasonably hard to spoof. It's what the server cap counts — an id that changes between restarts makes one
machine eat several slots and eventually hit SERVER_CAP_EXCEEDED. On the JVM, use
PersistentServerId.getOrCreate(dir): it persists a random id to your data folder
and reuses it (immune to hostname/MAC/container changes). In other languages, persist your own id at
install time — or derive one from a durable install/hardware identifier and hash it, avoiding volatile
signals like the hostname (in containers it's the container id and changes on every restart).
String raw = System.getProperty("os.name")
+ "|" + InetAddress.getLocalHost().getHostName();
MessageDigest md = MessageDigest.getInstance("SHA-256");
String serverId = HexFormat.of()
.formatHex(md.digest(raw.getBytes(UTF_8)));
import { createHash } from "node:crypto";
import os from "node:os";
const raw = `${os.platform()}|${os.hostname()}`;
const serverId = createHash("sha256").update(raw).digest("hex");
import hashlib, platform, socket
raw = f"{platform.system()}|{socket.gethostname()}"
server_id = hashlib.sha256(raw.encode()).hexdigest()
using System.Security.Cryptography;
using System.Text;
var raw = $"{Environment.OSVersion.Platform}|{Environment.MachineName}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
var serverId = Convert.ToHexString(bytes).ToLowerInvariant();
raw := runtime.GOOS + "|" + must(os.Hostname())
sum := sha256.Sum256([]byte(raw))
serverId := hex.EncodeToString(sum[:])
Response
The HTTP status carries the outcome — 200 granted, 4xx denied — and the body is always signed (see Verifying the signature).
// Granted (200)
{
"valid": true,
"productCode": "YOUR_PRODUCT",
"tier": "PREMIUM",
"expiresAt": "2027-01-01T00:00:00Z",
"featureFlags": { "export": true, "api": true, "seats": 5 },
"nonce": "...",
"serverTime": "2026-08-09T00:00:00Z"
}
// Denied (403 / 404 / 400 / 429)
{ "valid": false, "reason": "SERVER_CAP_EXCEEDED", "nonce": "...", "serverTime": "..." }expiresAt— ISO-8601 UTC; omitted when the license is perpetual.featureFlags— an arbitrary map you define per tier or per license.nonce— echoes your request nonce, so you can match reply to request.
Deny reasons
When valid is false, reason is one of:
| LICENSE_NOT_FOUND | No such key for this tenant. |
| PRODUCT_MISMATCH | Key isn't for this productCode. |
| LICENSE_SUSPENDED | Temporarily disabled. |
| LICENSE_REVOKED | Permanently killed. |
| EXPIRED | Past expiresAt. |
| SERVER_CAP_EXCEEDED | Too many distinct machines. |
| IP_LIMIT_EXCEEDED | Too many distinct IPs for this machine. |
| LICENSE_/IP_/SERVER_/CUSTOMER_BLACKLISTED | Blacklisted on that dimension. |
| NONCE_REPLAY | Nonce was already used. |
| RATE_LIMITED | Too many requests. |
| TENANT_SUSPENDED / TENANT_NOT_FOUND | Tenant-level problem. |
| SIGNATURE_INVALID | Set by your client when the signature fails to verify. |
Enforcement dimensions
- serverId → server cap. How many distinct machines a license may run on.
- IP per server → IP limit. How many distinct IPs are allowed per activated machine.
Both are set per license (or inherited from its tier) in the panel. Exceeding either denies the request with the matching reason above.
A raw call
No SDK needed to try it — any HTTP client works:
curl -X POST https://licenses.rymga.com/t/acme/api/v1/validate \
-H "Content-Type: application/json" \
-d '{
"licenseKey": "RYMGA-7QX4-9F2K-M8A2",
"productCode": "MY_APP",
"serverId": "3f9a...c1",
"nonce": "0d1e2f3a-4b5c-6d7e-8f90-a1b2c3d4e5f6",
"timestamp": 1733600000000
}' -i # -i to also see X-Signature / X-Kid headers
const res = await fetch(
"https://licenses.rymga.com/t/acme/api/v1/validate",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
licenseKey: key, productCode: "MY_APP",
serverId, nonce: crypto.randomUUID(),
timestamp: Date.now(),
}),
},
);
const signature = res.headers.get("X-Signature");
const bodyBytes = new Uint8Array(await res.arrayBuffer());
// verify signature over bodyBytes, then JSON.parse — see next page
import json, time, uuid, urllib.request
body = json.dumps({
"licenseKey": key, "productCode": "MY_APP",
"serverId": server_id, "nonce": str(uuid.uuid4()),
"timestamp": int(time.time() * 1000),
}).encode()
req = urllib.request.Request(
"https://licenses.rymga.com/t/acme/api/v1/validate",
data=body, headers={"Content-Type": "application/json"})
resp = urllib.request.urlopen(req)
raw = resp.read() # verify over these exact bytes
signature = resp.headers["X-Signature"]
valid: true means
nothing until you've verified X-Signature. That's the next page.