POST /api/endpoint/validate/license
One POST endpoint does everything: it authenticates your product key, loads the license, applies every enforcement rule, binds a new HWID or IP if there is room, and returns the result with a signed token.
Request
- Method
- POST
- URL
- https://www.uliseslicenses.xyz/api/endpoint/validate/license
- Content type
- application/json
- Response
- application/json
curl -i -X POST https://www.uliseslicenses.xyz/api/endpoint/validate/license \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "apiKey": "ulises_3f9a1c7e5b2d40819ac6e3f7b0d5a812", "productId": "65f1c2a4b9d84e37a0c5119f", "licenseKey": "ULS-XXXX-XXXX-XXXX", "hwid": "9A2B7C1D-44E5-4F60-8B9A-0C1D2E3F4A5B", "nonce": "b3f1c8d2a9e04756", "version": "1.4.2"}'Request fields
| Field | Type | Required | Meaning |
|---|---|---|---|
| apiKey | string | yes | Per-product key, format ulises_ followed by 32 lowercase hex characters. It identifies the product and authenticates the developer at the same time. It is not the user credential, and it is never issued to your buyers. |
| productId | string | yes | The product id. It must match the product the apiKey belongs to, otherwise the request is rejected with Product does not match the API key. |
| licenseKey | string | yes | The end user's license key, exactly as they entered it in your plugin. |
| hwid | string | conditional | Hardware id of the machine running the plugin. Required when the product has HWID enforcement enabled. Accepted charset is A-Fa-f0-9:- with 8 to 128 characters, and it is case insensitive because the value is uppercased server side. |
| ip | string | no | The public IP the license should be bound to. When omitted the server uses the source address it observed on the connection. |
| nonce | string | no | 8 to 128 characters from A-Za-z0-9._:- . When present it is single use: a second request reusing it is rejected with 409 Replay detected. The server always returns a nonce in the response, whether you sent one or not. |
| version | string | no | The plugin version. Optional, used only for telemetry. |
Successful response
HTTP 200 with success: true. A 200 is the only response that means the license passed, so a token is always present.
{ "success": true, "message": "Valid license", "type": "lifetime", "status": "active", "expiresAt": "2027-01-01T00:00:00.000Z", "data": { "product": { "id": "65f1c2a4b9d84e37a0c5119f", "name": "MyBestPlugin", "description": "Anti cheat suite for survival servers" }, "license": { "discordId": "1216532655592439862", "resource": "MyBestPlugin", "licenseKey": "ULS-XXXX-XXXX-XXXX", "issuedBy": "1216532655592439862", "expiresAt": "2027-01-01T00:00:00.000Z", "status": "active", "ipLimit": 3, "ips": [], "subType": "lifetime", "hwid": "9A2B7C1D-44E5-4F60-8B9A-0C1D2E3F4A5B" }, "updateInfo": { "available": true, "version": "1.2.3", "jarUrl": null } }, "token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IlVMUy1MSUMiLCJraWQiOiIyMDI2LTEwIn0.eyJ2IjoyLCJpc3MiOiJodHRwczovL3d3dy51bGlzZXNsaWNlbnNlcy54eXoiLCJhdWQiOiI2NWYxYzJhNGI5ZDg0ZTM3YTBjNTExOWYiLCJqdGkiOiJiM2YxYzhkMmE5ZTA0NzU2IiwiaWF0IjoxNzY3MjI1NjAwLCJuYmYiOjE3NjcyMjUzMDAsImV4cCI6MTc2NzI0MDAwMCwiaHdpZCI6IjlBMkI3QzFELTQ0RTUtNEY2MC04QjlBLTBDMUQyRTNGNEE1QiIsInN0IjoiYWN0aXZlIiwiZGF0YSI6eyJwcm9kdWN0Ijp7ImlkIjoiNjVmMWMyYTRiOWQ4NGUzN2EwYzUxMTlmIiwibmFtZSI6Ik15QmVzdFBsdWdpbiIsImRlc2NyaXB0aW9uIjoiQW50aSBjaGVhdCBzdWl0ZSBmb3Igc3Vydml2YWwgc2VydmVycyJ9LCJsaWNlbnNlIjp7ImRpc2NvcmRJZCI6IjEyMTY1MzI2NTU1OTI0Mzk4NjIiLCJyZXNvdXJjZSI6Ik15QmVzdFBsdWdpbiIsImxpY2Vuc2VLZXkiOiJVTFMtWFhYWC1YWFhYLVhYWFgiLCJpc3N1ZWRCeSI6IjEyMTY1MzI2NTU1OTI0Mzk4NjIiLCJleHBpcmVzQXQiOiIyMDI3LTAxLTAxVDAwOjAwOjAwLjAwMFoiLCJzdGF0dXMiOiJhY3RpdmUiLCJpcExpbWl0IjozLCJpcHMiOltdLCJzdWJUeXBlIjoibGlmZXRpbWUiLCJod2lkIjoiOUEyQjdDMUQtNDRFNS00RjYwLThCOUEtMEMxRDJFM0Y0QTVCIn0sInVwZGF0ZUluZm8iOnsiYXZhaWxhYmxlIjp0cnVlLCJ2ZXJzaW9uIjoiMS4yLjMiLCJqYXJVcmwiOm51bGx9fX0.TkJ_3UZLWelVvv0S0_PcVJlRTjWuG7as3EB_cbA_XYFQvp5EoG1sWAF5FiRyXsr3Go36BFpye3h-UlHeLb2e2Q", "nonce": "b3f1c8d2a9e04756"}- type: The license tier label, for example lifetime or enterprise. The SDK exposes the same value as subType.
- data: Exactly the tree UlisesLib returns, so existing parsing code and field names carry over unchanged.
- ips: Always an empty array. The server never discloses which addresses are bound to a license, so nothing here leaks another customer's infrastructure.
- updateInfo: Null when the product has no published version. When it is present it tells you a version exists and what it is. Downloading it is not part of this contract.
- token: The compact JWS. Verify it before you trust anything you parsed. See Verifying the Response.
- nonce: Always present, and always the value that is now consumed. Echo it into your logs for support, never a license key.
Calling it from Java
java.net.http is in the JDK, and Gson ships with every PaperMC and Spigot server, so this needs no dependency at all. Build the nonce fresh for every attempt, and verify the returned token before you act on anything you parsed.
import com.google.gson.JsonObject;import com.google.gson.JsonParser;import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;import java.security.SecureRandom;import java.time.Duration;import java.util.HexFormat; String url = "https://www.uliseslicenses.xyz/api/endpoint/validate/license"; byte[] noise = new byte[8];new SecureRandom().nextBytes(noise);String nonce = HexFormat.of().formatHex(noise); String body = "{" + "\"apiKey\":\"" + apiKey + "\"," + "\"productId\":\"" + productId + "\"," + "\"licenseKey\":\"" + licenseKey + "\"," + "\"hwid\":\"" + hwid + "\"," + "\"nonce\":\"" + nonce + "\"," + "\"version\":\"" + pluginVersion + "\"" + "}"; HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(10)) .header("Content-Type", "application/json") .header("Accept", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 200) { JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); String token = json.get("token").getAsString(); if (LicenseTokenVerifier.verify(token, productId, hwid)) { startPlugin(json); }} else { String message = JsonParser.parseString(response.body()).getAsJsonObject().get("message").getAsString(); getLogger().warning("License check failed: " + message);}Error responses
Every error is success: false with a message and, where it helps your code branch, one extra flag.
| Status | message | Extra flag |
|---|---|---|
| 400 | Invalid request body | none |
| 400 | Missing required fields | none |
| 400 | License has expired | none |
| 400 | HWID is required | requiresHwid: true |
| 401 | Invalid API key | none |
| 403 | Product is not active | none |
| 403 | Product does not match the API key | none |
| 403 | HWID limit reached | hwidMismatch: true |
| 403 | IP limit reached | ipMismatch: true |
| 404 | No valid license found | none |
| 409 | Replay detected | none |
| 429 | Daily quota exceeded | quotaExceeded: true, plus X-Quota-* and Retry-After headers |
| 429 | Rate limit exceeded | X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After headers |
| 500 | Internal server error | none |
Rate limits and the daily quota
Every response carries your remaining allowance. A 429 never means the license is bad, so wait for Retry-After and retry with exponential backoff.
| Header | Meaning |
|---|---|
| X-Quota-Limit | The daily request allowance for the plan that owns this product. The literal string unlimited for Pro. |
| X-Quota-Remaining | Requests left in the current window. Unlimited for Pro. |
| X-Quota-Reset | Seconds until the window resets. Absent for Pro. |
| Scope | Limit | Applies to |
|---|---|---|
| Daily quota per API key | 4,000 requests/day | Rolling 24 hour window starting at your first request of the window, not a calendar day, so you cannot double up around midnight. Pro plans have no limit. |
| Per license key | 30 requests/minute | Rolling window, regardless of which product or API key it arrives with. |
| Per observed source IP | 300 requests/minute | Applied before anything else, as a flood guard. |
Caching
Every response carries Cache-Control: no-store. Do not put an intermediary in front of this endpoint, and do not let your HTTP client reuse a cached response for a different license.
Caching in your own plugin is a different thing and you should do it. Validate on startup, then reuse the result for a minute or two before asking again. On a Free plan that is the difference between a few hundred requests a day and several thousand, and it keeps you clear of the quota on a busy server.
UlisesLib.jar does the same validation for you, with retries, HWID collection and the auto-updater built in. It is a dependency you shade into your jar rather than a contract you implement.
Using the SDKEndpoint reference questions
Where do I get the apiKey?
From your product page in the developer dashboard. It is per product, it starts with ulises_ followed by 32 hex characters, and it authenticates you rather than the buyer.
Can I put the license key in the URL?
No. The endpoint is POST only and takes a JSON body. License keys never belong in a query string, where they end up in proxy logs and browser history.
Does every response carry a token?
Only successful ones. Every 200 carries a token, and only a 200 means the license passed, so verifying the token is a second gate on the same decision.
What happens on the first run of a new machine?
The first request binds the HWID to the license if the license has room under its HWID limit. If it does not, you get 403 HWID limit reached with hwidMismatch set.
How should I handle a 429?
Wait for Retry-After seconds, or back off exponentially starting around one second. A 429 is transient and never means the license is bad.
Built by Ulises - Citymoon Dynamics