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

http
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

FieldRequiredDescription
license_keyYesThe license key in PL-XXXX-XXXX-XXXX-XXXX format.
product_slugYesThe URL-friendly product identifier shown in the product settings.
identity_valueYes*The value to bind this check to. See Protection profiles below.
fqdnNoA 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.

json
{
  "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.

json
{
  "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.

json
{
  "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):

json
{
  "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):

json
{
  "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

bash
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

bash
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

bash
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

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

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#

csharp
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

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

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

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
<?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_key field 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

  1. Create a product in the dashboard and note its slug and public key.
  2. Choose a protection profile and issue a test license key.
  3. Copy the code template for your language above.
  4. Replace license_key, product_slug, and identity_value with real values.
  5. Call the endpoint on app startup or at your feature gate.
  6. Verify the Ed25519 signature with the product's public key if you are enforcing licenses offline or client-side.
  7. Handle denial results gracefully (expired, seat limit, access rule, etc.).

Error codes

result / errorHTTPMeaning
allowed200License valid; response is signed.
denied_invalid_key404License key not found or does not belong to this product.
denied_expired403License has passed its expiry date.
denied_revoked403License was revoked by an admin or owner.
denied_suspended403License is suspended.
denied_seat_limit403Maximum activations reached, or a none license was already used once.
denied_rate_limit429Too many checks from this IP or key; see Retry-After.
denied_ip403Blocked by an IP/CIDR access rule.
denied_fqdn403Blocked by an FQDN access rule.
invalid_json400Request body could not be parsed as JSON.
invalid_request400Request body failed schema validation (missing/malformed fields).
activation_insert_failed500Transient error creating the activation row; retry.

For denied_rate_limit responses, the Retry-After header indicates how many seconds to wait before retrying.