License API
Validate license keys and receive signed entitlement responses for native and web apps.
The License API is the public endpoint your client applications call at runtime. Send a license key, product slug, and identity value; the server returns a signed entitlement payload that your client can verify offline with the product's Ed25519 public key.
Endpoint
POST https://pingless-license-system.vercel.app/api/v1/license/check
Content-Type: application/json
If you are self-hosting, replace https://pingless-license-system.vercel.app with your own deployment URL.
Request format
| Field | Required | Description |
|---|---|---|
license_key | Yes | The license key in PL-XXXX-XXXX-XXXX-XXXX format. |
product_slug | Yes | The URL-friendly product identifier shown in the product settings. |
identity_value | Yes* | The value to bind this check to. See Protection profiles below. |
fqdn | No | A literal FQDN (e.g. app.example.com) for access-rule evaluation. |
* identity_value is always required by the API. For unbound (none) products, pass a fixed sentinel value so the same seat is reused on the first check.
Protection profiles
Every product has a protection profile that controls how strictly a license is tied to a device or domain. Choose the profile when you create or edit a product.
none — License key only
No per-device or per-domain binding. The license is valid anywhere the key is used. Send the same fixed sentinel value for identity_value on every check so the platform reuses a single activation seat on the first check.
{
"license_key": "PL-ABCD-EFGH-IJKL-MNOP",
"product_slug": "my-saas",
"identity_value": "0000000000000000000000000000000000000000000000000000000000000000"
}
A none license can only be used once. After the first successful check creates an activation, subsequent checks return denied_seat_limit.
hostname — Bind to domain/FQDN
Best for web apps, WordPress plugins, browser extensions, and SaaS backends. The license is bound to the hostname or domain the customer runs the software on.
{
"license_key": "PL-ABCD-EFGH-IJKL-MNOP",
"product_slug": "my-saas",
"identity_value": "example.com",
"fqdn": "app.example.com"
}
The server normalizes the domain (lowercase, strips protocol/path/www.) and counts each unique normalized domain as one activation seat.
hardware — Bind to HWID/device fingerprint
Best for native desktop/server apps, CLI tools, and appliances. The license is bound to a hardware fingerprint generated client-side.
{
"license_key": "PL-ABCD-EFGH-IJKL-MNOP",
"product_slug": "my-native-app",
"identity_value": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456",
"fqdn": "web01.dc1.example.com"
}
identity_value must be a 64-character lowercase hex SHA-256 hardware ID. See license-server-spec.md (Section C) for how to compute it from machine-id, MAC address, and boot-disk serial.
Response format
Success (200):
{
"valid": true,
"expires_at": 1750000000,
"product_slug": "my-saas",
"features": {
"tier": "pro",
"api_access": true,
"offline_days": 7
},
"signed_at": 1700000000,
"signature": "base64-encoded-ed25519-signature"
}
The signature covers the entire payload minus the signature field. Native clients and web backends should verify it with the product's Ed25519 public key before trusting the response.
Denied (4xx):
{
"valid": false,
"result": "denied_seat_limit"
}
The result field tells you exactly why the check was denied. See the error codes table below.
Code examples
curl — Hardware-bound license
curl -X POST https://pingless-license-system.vercel.app/api/v1/license/check \
-H "Content-Type: application/json" \
-d '{
"license_key": "PL-ABCD-EFGH-IJKL-MNOP",
"product_slug": "my-native-app",
"identity_value": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456",
"fqdn": "web01.dc1.example.com"
}'
curl — Hostname-bound license
curl -X POST https://pingless-license-system.vercel.app/api/v1/license/check \
-H "Content-Type: application/json" \
-d '{
"license_key": "PL-ABCD-EFGH-IJKL-MNOP",
"product_slug": "my-saas",
"identity_value": "example.com",
"fqdn": "app.example.com"
}'
curl — Unbound license
curl -X POST https://pingless-license-system.vercel.app/api/v1/license/check \
-H "Content-Type: application/json" \
-d '{
"license_key": "PL-ABCD-EFGH-IJKL-MNOP",
"product_slug": "my-saas",
"identity_value": "0000000000000000000000000000000000000000000000000000000000000000"
}'
Node.js / TypeScript
const API_BASE = "https://pingless-license-system.vercel.app";
interface LicenseCheck {
license_key: string;
product_slug: string;
identity_value: string;
fqdn?: string;
}
interface LicenseResponse {
valid: boolean;
result?: string;
expires_at?: number;
product_slug?: string;
features?: Record<string, unknown>;
signed_at?: number;
signature?: string;
error?: string;
detail?: string;
}
async function checkLicense(payload: LicenseCheck): Promise<LicenseResponse> {
const res = await fetch(`${API_BASE}/api/v1/license/check`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = (await res.json()) as LicenseResponse;
if (!res.ok || !data.valid) {
throw new Error(`License denied: ${data.result ?? data.error}`);
}
return data;
}
// Example
checkLicense({
license_key: "PL-ABCD-EFGH-IJKL-MNOP",
product_slug: "my-saas",
identity_value: "example.com",
}).then((r) => console.log("Valid until", r.expires_at));
Python
import os
import requests
API_BASE = os.environ.get("PINGLESS_API_URL", "https://pingless-license-system.vercel.app")
def check_license(
license_key: str,
product_slug: str,
identity_value: str,
fqdn: str | None = None,
):
payload = {
"license_key": license_key,
"product_slug": product_slug,
"identity_value": identity_value,
}
if fqdn:
payload["fqdn"] = fqdn
resp = requests.post(
f"{API_BASE}/api/v1/license/check",
json=payload,
headers={"Content-Type": "application/json"},
timeout=10,
)
data = resp.json()
if not resp.ok or not data.get("valid"):
raise RuntimeError(f"License denied: {data.get('result') or data.get('error')}")
return data
# Example
if __name__ == "__main__":
result = check_license(
license_key="PL-ABCD-EFGH-IJKL-MNOP",
product_slug="my-saas",
identity_value="example.com",
)
print("Valid until", result.get("expires_at"))
C#
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
public class LicenseCheckPayload
{
[JsonPropertyName("license_key")]
public required string LicenseKey { get; set; }
[JsonPropertyName("product_slug")]
public required string ProductSlug { get; set; }
[JsonPropertyName("identity_value")]
public required string IdentityValue { get; set; }
[JsonPropertyName("fqdn")]
public string? Fqdn { get; set; }
}
public class LicenseResponse
{
[JsonPropertyName("valid")]
public bool Valid { get; set; }
[JsonPropertyName("result")]
public string? Result { get; set; }
[JsonPropertyName("expires_at")]
public long? ExpiresAt { get; set; }
[JsonPropertyName("signature")]
public string? Signature { get; set; }
}
public class PinglessClient
{
private static readonly HttpClient Http = new();
private const string ApiBase = "https://pingless-license-system.vercel.app";
public static async Task<LicenseResponse> CheckLicenseAsync(LicenseCheckPayload payload)
{
var res = await Http.PostAsJsonAsync($"{ApiBase}/api/v1/license/check", payload);
var data = await res.Content.ReadFromJsonAsync<LicenseResponse>()
?? throw new InvalidOperationException("Empty response");
if (!res.IsSuccessStatusCode || !data.Valid)
{
throw new InvalidOperationException($"License denied: {data.Result}");
}
return data;
}
}
Go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
)
const apiBase = "https://pingless-license-system.vercel.app"
type LicenseCheckPayload struct {
LicenseKey string `json:"license_key"`
ProductSlug string `json:"product_slug"`
IdentityValue string `json:"identity_value"`
Fqdn string `json:"fqdn,omitempty"`
}
type LicenseResponse struct {
Valid bool `json:"valid"`
Result string `json:"result,omitempty"`
ExpiresAt int64 `json:"expires_at,omitempty"`
Features map[string]interface{} `json:"features,omitempty"`
Signature string `json:"signature,omitempty"`
}
func CheckLicense(payload LicenseCheckPayload) (*LicenseResponse, error) {
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", apiBase+"/api/v1/license/check", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var data LicenseResponse
if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
return nil, err
}
if res.StatusCode != http.StatusOK || !data.Valid {
return nil, fmt.Errorf("license denied: %s", data.Result)
}
return &data, nil
}
Rust
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
const API_BASE: &str = "https://pingless-license-system.vercel.app";
#[derive(Serialize)]
struct LicenseCheckPayload<'a> {
license_key: &'a str,
product_slug: &'a str,
identity_value: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
fqdn: Option<&'a str>,
}
#[derive(Deserialize, Debug)]
struct LicenseResponse {
valid: bool,
result: Option<String>,
expires_at: Option<i64>,
features: Option<HashMap<String, serde_json::Value>>,
signature: Option<String>,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let payload = LicenseCheckPayload {
license_key: "PL-ABCD-EFGH-IJKL-MNOP",
product_slug: "my-saas",
identity_value: "example.com",
fqdn: None,
};
let client = reqwest::Client::new();
let res = client
.post(format!("{API_BASE}/api/v1/license/check"))
.json(&payload)
.send()
.await?;
if !res.status().is_success() {
let err: LicenseResponse = res.json().await?;
return Err(format!("license denied: {}", err.result.unwrap_or_default()).into());
}
let data: LicenseResponse = res.json().await?;
println!("{:?}", data);
Ok(())
}
Java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
public class PinglessLicenseClient {
private static final String API_BASE = "https://pingless-license-system.vercel.app";
private static final ObjectMapper mapper = new ObjectMapper();
private static final HttpClient client = HttpClient.newHttpClient();
public static JsonNode checkLicense(
String licenseKey,
String productSlug,
String identityValue,
String fqdn
) throws Exception {
var bodyNode = mapper.createObjectNode()
.put("license_key", licenseKey)
.put("product_slug", productSlug)
.put("identity_value", identityValue);
if (fqdn != null) bodyNode.put("fqdn", fqdn);
var request = HttpRequest.newBuilder()
.uri(URI.create(API_BASE + "/api/v1/license/check"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(bodyNode)))
.build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
var data = mapper.readTree(response.body());
if (response.statusCode() != 200 || !data.path("valid").asBoolean()) {
throw new RuntimeException("License denied: " + data.path("result").asText());
}
return data;
}
}
PHP
<?php
function check_license(
string $license_key,
string $product_slug,
string $identity_value,
?string $fqdn = null
): array {
$api_base = "https://pingless-license-system.vercel.app";
$payload = [
"license_key" => $license_key,
"product_slug" => $product_slug,
"identity_value" => $identity_value,
];
if ($fqdn !== null) {
$payload["fqdn"] = $fqdn;
}
$ch = curl_init("{$api_base}/api/v1/license/check");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($body, true);
if ($status !== 200 || empty($data["valid"])) {
throw new RuntimeException("License denied: " . ($data["result"] ?? $data["error"] ?? "unknown"));
}
return $data;
}
Product public keys
Each product has its own Ed25519 key pair. The public key is available in:
- The
ed25519_public_keyfield of any product response from the management API. - The product details page in the dashboard (Ed25519 public key card).
Use this key — not a platform-wide key — to verify /api/v1/license/check signatures.
Rotating a product key
Admins and product owners can rotate the key from the product details page. Rotation increments an internal counter and derives a fresh deterministic key pair from the same master seed. Rotating invalidates every previously signed offline token — clients must update their embedded public key before they can verify new responses.
5-minute integration checklist
- Create a product in the dashboard and note its slug and public key.
- Choose a protection profile and issue a test license key.
- Copy the code template for your language above.
- Replace
license_key,product_slug, andidentity_valuewith real values. - Call the endpoint on app startup or at your feature gate.
- Verify the Ed25519 signature with the product's public key if you are enforcing licenses offline or client-side.
- Handle denial results gracefully (expired, seat limit, access rule, etc.).
Error codes
result / error | HTTP | Meaning |
|---|---|---|
allowed | 200 | License valid; response is signed. |
denied_invalid_key | 404 | License key not found or does not belong to this product. |
denied_expired | 403 | License has passed its expiry date. |
denied_revoked | 403 | License was revoked by an admin or owner. |
denied_suspended | 403 | License is suspended. |
denied_seat_limit | 403 | Maximum activations reached, or a none license was already used once. |
denied_rate_limit | 429 | Too many checks from this IP or key; see Retry-After. |
denied_ip | 403 | Blocked by an IP/CIDR access rule. |
denied_fqdn | 403 | Blocked by an FQDN access rule. |
invalid_json | 400 | Request body could not be parsed as JSON. |
invalid_request | 400 | Request body failed schema validation (missing/malformed fields). |
activation_insert_failed | 500 | Transient error creating the activation row; retry. |
For denied_rate_limit responses, the Retry-After header indicates how many seconds to wait before retrying.
