# License API

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

Source: https://shopfa.st/docs/guides/licensing

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**.

<div class="callout"><p><strong>Early access.</strong> 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 <strong>Catalog → Licenses</strong> do not show, buyers see their keys without a countdown, and this
endpoint answers <code>403 license-api-off</code>. Ask ShopFast support to try it.</p></div>

## 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

```http
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

```json
{
  "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](https://shopfa.st/docs/store/product-status#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

```ts
// 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

```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#

```csharp
// .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++

```cpp
// 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"
}
```
