Docs
Dashboard
View as Markdown
On this page
  1. How it works
  2. Request
  3. Answer
  4. Expiry
  5. Device binding
  6. Status pauses
  7. Limits and abuse
  8. Rotating keys
  9. Snippets
  10. TypeScript
  11. Python
  12. C#
  13. C++

License API

Validate keys from your app, bind them to devices, read their expiry, and verify every answer offline.

Sell keys with ShopFast and let your app ask ShopFast whether a key is good, for which plan and until when. No second licensing service, no second bill. Turn it on per product in Products → (your key product) → Licensing.

Early access. Licensing (this API, device binding and resets, time left, Extend and auto-pause) is turned on per store or per seller on request. Until it is on for your store, the Licensing section and Catalog → Licenses do not show, buyers see their keys without a countdown, and this endpoint answers 403 license-api-off. Ask ShopFast support to try it.

How it works

  1. Publishable key. Each product gets a pk_… key. It is safe to ship inside your app: it only validates keys of that one product and cannot read or change anything else.
  2. Validate. Your app sends the buyer’s key, a device id (hwid) and a fresh nonce: POST https://api.shopfa.st/v1/licenses/validate.
  3. Verify. Every answer is signed with the product’s Ed25519 key. Pin the public key (shown next to the publishable key) in your app and check the signature over signature.payload, then read the payload. Check three more things inside it: the nonce you sent comes back (a recorded answer cannot be replayed), hwidHash is the SHA-256 of your own device id (an answer fetched for another machine, for example by a local proxy that rewrites the hwid, is refused) and validUntil has not passed (answers count for 10 minutes).

Request

POST /v1/licenses/validate
content-type: application/json

{ "key": "IRVL-7D-8K2M-Q4XZ", "product": "pk_…", "hwid": "a1b2c3…", "nonce": "f3c9…", "deviceName": "Alex's PC" }
Field Required Notes
key yes The license key the buyer typed.
product yes The product’s publishable key.
hwid when device binding is on A stable device id. It is hashed with your store’s secret before it is stored; the raw value is never kept.
nonce recommended 1–64 letters, digits, - or _. Echoed inside the signed payload. Send a fresh one every time.
deviceName no Shown to the buyer in their device list.

Answer

{
  "status": "valid",
  "valid": true,
  "message": "The key is valid.",
  "licenseId": "key_…",
  "product": { "id": "prd_…", "name": "Ironveil Pro" },
  "plan": { "variantId": "var_…", "name": "7 Days", "duration": "7d", "label": "7 Days" },
  "expiresAt": "2026-10-28T14:02:11.000Z",
  "activatedAt": "2026-10-21T14:02:11.000Z",
  "devices": { "used": 1, "max": 1 },
  "metadata": { "channel": "stable", "build": 412 },
  "nonce": "f3c9…",
  "issuedAt": "2026-10-21T14:02:11.402Z",
  "hwidHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "validUntil": "2026-10-21T14:12:11.402Z",
  "signature": { "alg": "Ed25519", "keyId": "Qm9vLx…", "payload": "{\"status\":\"valid\",…}", "value": "…" }
}

status is one of:

Status Meaning
valid Run. expiresAt is null for Lifetime keys.
expired The time ran out. The buyer can extend the same key from their order page.
revoked You revoked the key (chargeback, sharing), or its order was refunded or lost a chargeback.
hwid_mismatch Device binding is on and the key is bound to other devices (or no hwid was sent). The buyer resets it from their order page.
paused You suspended the key, sales are paused because the product’s status is Not working or Down, or its order is disputed or on hold.
invalid Not a sold key of this product. Unknown keys, unsold keys and other products’ keys all get this same answer. TEST- keys from store test mode answer invalid once test mode is off (and after 24 hours).

Read the decision from the verified payload, never from the outer fields alone.

Expiry

The option’s name sets the plan: 1 Day, 7 Days, 30 Days, Lifetime (also 1 Week, Monthly, 90 Days…). Time starts at purchase, or at the first successful validation when you choose Time starts: at first use. Buyers see a live countdown on their order page and in their account, and Extend buys more time on the same key through a normal checkout. Webhooks license.expiring (three days before) and license.expired fire once per end date, and with email marketing (also early access) the key-expiry automation reminds buyers with a link straight to Extend.

Device binding

With Device binding on, the first device that validates a key binds to it, up to the limit you set. Buyers release their devices themselves on the order page, within your resets per 30 days and cooldown. You can release devices, change one key’s limit, add or take off time, suspend or revoke from Catalog → Licenses; every change is kept in the key’s history.

Status pauses

Link the product to a status page component and turn on Pause sales on status. While the component reports an outage (Not working or Down), the product cannot be bought (or is hidden), license checks answer paused, and key holders get an email. When it is Live again, sales resume and every key that was running gets the downtime added, once. Product status and auto-pause has every setting.

Limits and abuse

  • Checks per network and product: 60 a minute by default (10–600, per product). Over it: 429 with Retry-After.
  • Unknown keys: after 12 in 10 minutes from one network, that network gets 429 for this product for a while.
  • An unknown publishable key gets 401 unknown-product-key; a product with the API off gets 403 license-api-off.
  • Every check is logged for 30 days (never the key or the raw device id) and shows in the key’s history.

Rotating keys

Rotate → New publishable key stops the old pk_ at once. New signing key changes the pinned public key: ship a version of your app with the new key before you rotate, or accept both during a release.

Snippets

Each snippet sends the check, verifies the signature with the pinned key and returns the payload. The dashboard fills in your product’s keys.

TypeScript

// Node 20+, Bun or Deno. Verifies the signed answer offline with the pinned key.
const PRODUCT = 'pk_YOUR_PUBLISHABLE_KEY';
const PUBLIC_KEY = 'YOUR_PINNED_PUBLIC_KEY';

export async function checkLicense(key: string, hwid: string) {
  const nonce = crypto.randomUUID().replaceAll('-', '');
  const res = await fetch('https://api.shopfa.st/v1/licenses/validate', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ key, product: PRODUCT, hwid, nonce }),
  });
  if (!res.ok) throw new Error(`License server answered ${res.status}`);
  const { signature } = await res.json();
  const pub = await crypto.subtle.importKey('raw', Buffer.from(PUBLIC_KEY, 'base64url'), { name: 'Ed25519' }, false, ['verify']);
  const ok = await crypto.subtle.verify({ name: 'Ed25519' }, pub, Buffer.from(signature.value, 'base64url'), new TextEncoder().encode(signature.payload));
  if (!ok) throw new Error('Signature does not match');
  const answer = JSON.parse(signature.payload);
  if (answer.nonce !== nonce) throw new Error('Replayed answer');
  const mine = Buffer.from(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(hwid))).toString('hex');
  if (answer.hwidHash !== mine) throw new Error('Answer was issued for another device');
  if (Date.parse(answer.validUntil) < Date.now()) throw new Error('Answer expired');
  return answer; // { status: 'valid' | 'expired' | 'revoked' | 'hwid_mismatch' | 'paused' | 'invalid', expiresAt, plan, metadata }
}

Python

# pip install requests cryptography
import base64, hashlib, json, secrets, requests
from datetime import datetime, timezone
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

PRODUCT = "pk_YOUR_PUBLISHABLE_KEY"
PUBLIC_KEY = "YOUR_PINNED_PUBLIC_KEY"

def b64u(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def check_license(key: str, hwid: str) -> dict:
    nonce = secrets.token_hex(16)
    r = requests.post("https://api.shopfa.st/v1/licenses/validate", json={"key": key, "product": PRODUCT, "hwid": hwid, "nonce": nonce}, timeout=10)
    r.raise_for_status()
    sig = r.json()["signature"]
    Ed25519PublicKey.from_public_bytes(b64u(PUBLIC_KEY)).verify(b64u(sig["value"]), sig["payload"].encode())  # raises if forged
    answer = json.loads(sig["payload"])
    if answer["nonce"] != nonce:
        raise ValueError("replayed answer")
    if answer["hwidHash"] != hashlib.sha256(hwid.encode()).hexdigest():
        raise ValueError("answer was issued for another device")
    if datetime.fromisoformat(answer["validUntil"].replace("Z", "+00:00")) < datetime.now(timezone.utc):
        raise ValueError("answer expired")
    return answer  # answer["status"] == "valid", answer["expiresAt"], answer["plan"], answer["metadata"]

C#

// .NET 8. dotnet add package NSec.Cryptography
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using NSec.Cryptography;

static class License
{
    const string Product = "pk_YOUR_PUBLISHABLE_KEY";
    const string PublicKey = "YOUR_PINNED_PUBLIC_KEY";
    static readonly HttpClient Http = new();

    static byte[] B64u(string s) => Convert.FromBase64String(s.Replace('-', '+').Replace('_', '/').PadRight(s.Length + (4 - s.Length % 4) % 4, '='));

    public static async Task<JsonElement> CheckAsync(string key, string hwid)
    {
        var nonce = Convert.ToHexString(Guid.NewGuid().ToByteArray());
        var res = await Http.PostAsJsonAsync("https://api.shopfa.st/v1/licenses/validate", new { key, product = Product, hwid, nonce });
        res.EnsureSuccessStatusCode();
        var sig = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("signature");
        var payload = sig.GetProperty("payload").GetString()!;
        var pub = NSec.Cryptography.PublicKey.Import(SignatureAlgorithm.Ed25519, B64u(PublicKey), KeyBlobFormat.RawPublicKey);
        if (!SignatureAlgorithm.Ed25519.Verify(pub, Encoding.UTF8.GetBytes(payload), B64u(sig.GetProperty("value").GetString()!)))
            throw new Exception("Signature does not match");
        var answer = JsonDocument.Parse(payload).RootElement;
        if (answer.GetProperty("nonce").GetString() != nonce) throw new Exception("Replayed answer");
        var mine = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(hwid))).ToLowerInvariant();
        if (answer.GetProperty("hwidHash").GetString() != mine) throw new Exception("Answer was issued for another device");
        if (DateTimeOffset.Parse(answer.GetProperty("validUntil").GetString()!) < DateTimeOffset.UtcNow) throw new Exception("Answer expired");
        return answer; // answer.GetProperty("status").GetString() == "valid"
    }
}

C++

// C++17 with libcurl, libsodium and nlohmann/json.
#include <curl/curl.h>
#include <sodium.h>
#include <nlohmann/json.hpp>
#include <stdexcept>
#include <string>
#include <vector>

static const char* PRODUCT = "pk_YOUR_PUBLISHABLE_KEY";
static const char* PUBLIC_KEY = "YOUR_PINNED_PUBLIC_KEY";

static std::vector<unsigned char> b64u(const std::string& s) {
  std::vector<unsigned char> out(s.size());
  size_t len = 0;
  sodium_base642bin(out.data(), out.size(), s.c_str(), s.size(), nullptr, &len, nullptr, sodium_base64_VARIANT_URLSAFE_NO_PADDING);
  out.resize(len);
  return out;
}

static size_t collect(char* p, size_t n, size_t m, void* out) { static_cast<std::string*>(out)->append(p, n * m); return n * m; }

nlohmann::json check_license(const std::string& key, const std::string& hwid) {
  if (sodium_init() < 0) throw std::runtime_error("libsodium");
  unsigned char raw[16]; randombytes_buf(raw, sizeof raw);
  char nonce[33]; sodium_bin2hex(nonce, sizeof nonce, raw, sizeof raw);
  std::string body = nlohmann::json{{"key", key}, {"product", PRODUCT}, {"hwid", hwid}, {"nonce", nonce}}.dump(), res;
  CURL* c = curl_easy_init();
  curl_slist* h = curl_slist_append(nullptr, "content-type: application/json");
  curl_easy_setopt(c, CURLOPT_URL, "https://api.shopfa.st/v1/licenses/validate");
  curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
  curl_easy_setopt(c, CURLOPT_POSTFIELDS, body.c_str());
  curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, collect);
  curl_easy_setopt(c, CURLOPT_WRITEDATA, &res);
  CURLcode rc = curl_easy_perform(c);
  curl_slist_free_all(h); curl_easy_cleanup(c);
  if (rc != CURLE_OK) throw std::runtime_error("network");
  auto sig = nlohmann::json::parse(res).at("signature");
  std::string payload = sig.at("payload");
  auto pub = b64u(PUBLIC_KEY), s = b64u(sig.at("value"));
  if (s.size() != crypto_sign_BYTES || crypto_sign_verify_detached(s.data(), reinterpret_cast<const unsigned char*>(payload.data()), payload.size(), pub.data()) != 0)
    throw std::runtime_error("signature does not match");
  auto answer = nlohmann::json::parse(payload);
  if (answer.at("nonce") != nonce) throw std::runtime_error("replayed answer");
  unsigned char digest[crypto_hash_sha256_BYTES];
  crypto_hash_sha256(digest, reinterpret_cast<const unsigned char*>(hwid.data()), hwid.size());
  char mine[crypto_hash_sha256_BYTES * 2 + 1]; sodium_bin2hex(mine, sizeof mine, digest, sizeof digest);
  if (answer.at("hwidHash") != std::string(mine)) throw std::runtime_error("answer was issued for another device");
  // validUntil is ISO 8601 UTC; compare it with your clock (e.g. std::chrono + date parsing) and refuse it once passed.
  return answer;  // answer["status"] == "valid"
}
Was this page helpful?