rymga ← Back to Licenses

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 }
FieldRequiredNotes
licenseKeyyesThe key issued to your customer.
productCodeyesMust match the product the license belongs to.
serverIdyesStable fingerprint of the machine — drives the server cap.
labelnoFree label shown next to the activation in your panel.
nonceyesUnique per request (a UUID). Replays are rejected within a short window.
timestampyesEpoch 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[:])
Pick durable inputs. Anything that changes on every launch (a random UUID, a PID) will burn a new server slot each run. Persist your fingerprint or derive it from stable hardware.

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_FOUNDNo such key for this tenant.
PRODUCT_MISMATCHKey isn't for this productCode.
LICENSE_SUSPENDEDTemporarily disabled.
LICENSE_REVOKEDPermanently killed.
EXPIREDPast expiresAt.
SERVER_CAP_EXCEEDEDToo many distinct machines.
IP_LIMIT_EXCEEDEDToo many distinct IPs for this machine.
LICENSE_/IP_/SERVER_/CUSTOMER_BLACKLISTEDBlacklisted on that dimension.
NONCE_REPLAYNonce was already used.
RATE_LIMITEDToo many requests.
TENANT_SUSPENDED / TENANT_NOT_FOUNDTenant-level problem.
SIGNATURE_INVALIDSet 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"]
Do not trust the JSON yet. A 200 with valid: true means nothing until you've verified X-Signature. That's the next page.