License API
One licence key activates, validates and deactivates any product you build — over HTTPS, from any language, on any host. No database credentials, no shared hosting requirement.
How it works
Every purchase on Shawon Web issues one licence key in the form
SW-XXXXX-XXXXX-XXXXX-XXXXX. That key is the API key. Your product
sends it as a Bearer token and gets back everything it needs: whether it may run, which
plan tier the customer is on, when the licence expires, and how many installations are
already using it.
One credential
No separate API key, client id or HMAC secret to configure. The customer pastes the key once.
Runs anywhere
Plain HTTPS + JSON. Your product does not need to share our hosting or our database.
Re-activates itself
Renew or re-buy on the website and the installation comes back on its own — the key is never typed again.
One licence, one installation
By default every licence is permanently bound to the
first installation that activates it. The same machine may reinstall, reactivate and
renew as often as it likes. A second machine using the same key is refused with
license_already_activated,
and neither the product nor the customer can release the slot to free it up —
/license/deactivate
returns deactivation_not_permitted.
If the machine is genuinely gone — hardware failure, decommissioned server, a rebuild from scratch — the customer contacts Shawon Web support and we reopen the licence for exactly one new activation. The old installation is retired permanently at that point, so it can never take the slot back.
Some plans are sold as multi-seat instead. Those licences report
seats.policy: "flexible" and seats.transferable: true, and there the
customer can release an installation from their dashboard and deactivate works
normally. Read seats.policy rather than assuming — if it is
strict, do not show a "deactivate" or "move licence" button at all.
The one rule that makes re-activation automatic
Store the licence key locally the first time it is entered, and send the same
instance_id on every call. The API remembers that installation forever.
When a lapsed licence is renewed on the website, the next /license/validate
returns valid: true and your product simply unlocks — no prompt, no
re-entry, no support ticket.
Concepts
| Term | Meaning |
|---|---|
license_key | The customer-facing credential and the API key. Sent as Authorization: Bearer. |
product_code | Identifies your product (e.g. SW-ERP-001). Send it on every call — the API rejects a key issued for a different product, which is what makes one API safe to share across many products. |
instance_id | A stable id your product generates once per installation and keeps forever (a GUID in a config file, or a machine fingerprint). It is what an activation slot is keyed on. |
instance_token | Returned at activation. May be used in place of the licence key on later calls, so the raw key never has to be kept in memory. |
signing_secret | Returned once at activation. Used to verify offline tokens and webhook signatures. Never sent by your product as a credential. |
| Activation policy | strict (default) binds the key to its first installation for good; only support can reopen it. flexible allows transferable seats. Reported as seats.policy. |
| Seat | One concurrent activation. A strict licence has exactly one and it is not transferable; a flexible licence has seats.max of them and deactivating frees one. |
| Grace period | Days after expiry during which the product keeps working (status: "grace") so a late renewal never locks a customer out mid-shift. |
Authentication
Send the licence key as a Bearer token on every request. All traffic must use HTTPS.
Authorization: Bearer SW-A1B2C-D3E4F-5G6H7-8I9J0
If your HTTP stack cannot set Authorization, the API also accepts the
X-License-Key header, or a license_key field in the JSON body.
After activation you may send the instance_token instead of the key — the API
detects which one you used from its shape.
Integration lifecycle
Four moments in the life of your product:
-
1
First run — activate
Ask for the licence key once. Call
/license/activate. Save the key, theinstance_idyou generated, and the returnedinstance_tokenandsigning_secret. -
2
Every launch — validate
Call
/license/validateand gate ondata.license.valid. Cache the response fordata.revalidate_afterseconds (24h) so a restart loop does not hammer the API. -
3
Renewal — nothing to do
When the customer renews or re-purchases on shawonweb.com, the same key gains a new expiry. The next scheduled validate returns
valid: trueand the product unlocks by itself. If the installation had been released, validate answersactivation_required: true— re-call activate with the stored key and it slots straight back in. -
4
Uninstall or migration — deactivate
Call
/license/deactivateto free the seat so the customer can move to a new machine.
Request & response format
Requests are JSON (Content-Type: application/json); form-encoded bodies and
query strings are accepted too. Every response uses the same envelope, so you can write
one parser for the whole API.
{
"success": true,
"data": { ... },
"meta": { "api_version": "2.0", "request_id": "...", "timestamp": "..." }
}
{
"success": false,
"error": { "code": "license_expired", "message": "...", "details": { ... } },
"meta": { ... }
}
Branch on error.code, never on the message text — messages are written for
humans and may be reworded. Every response carries an X-Request-Id header;
include it if you contact support.
Endpoint summary
| Method | Path | Purpose |
|---|---|---|
| POST | /license/activate | Claim an activation slot for this installation. |
| POST | /license/validate | Check whether this installation may run right now. |
| POST | /license/deactivate | Release this installation's slot. |
| POST | /license/heartbeat | Lightweight liveness ping and status check. |
| GET | /license/details | Full licence record including billing and every activation. |
| GET | /license/activations | List every installation running on this key. |
| POST | /license/offline-token | Signed blob for verifying the licence without network access. |
| GET | /ping | Unauthenticated health check. |
POST/license/activate
Claims an activation slot for this installation. Call it once, from your activation
screen. Calling it again for an installation the API already knows is always
safe: the existing slot is reclaimed, no extra seat is consumed, and the response
carries activation.reactivated: true.
| Field | Required | Notes |
|---|---|---|
instance_id | Required on strict licences | Stable per-installation id — generate a GUID once, store it, never change it. On a strict licence, omitting it returns instance_id_required: we refuse to guess, because a derived id would drift when the customer's IP or hostname changes and lock them out of software they paid for. On flexible licences it falls back to domain + hostname + platform. |
product_code | Recommended | Rejects keys belonging to another product with product_mismatch. |
device_name | Optional | Label shown to the customer in their dashboard. Make it recognisable. |
domain | Optional | Required if the customer locked the licence to a domain. Subdomains of the locked domain are allowed. |
hostname, platform | Optional | Diagnostics shown in the dashboard and in support tickets. |
version | Optional | Your product version. Lets us tell a customer they are running something old. |
environment | Optional | production (default), staging or development. |
metadata | Optional | Any JSON object you want stored against the installation. |
curl -X POST https://shawonweb.com/api/v1/license/activate \
-H "Authorization: Bearer SW-A1B2C-D3E4F-5G6H7-8I9J0" \
-H "Content-Type: application/json" \
-d '{
"product_code": "SW-ERP-001",
"instance_id": "9f2c1b7e-4d55-4a1e-9c0b-7f3a2e1d8c44",
"device_name": "Head office server",
"domain": "erp.acme-corp.com",
"hostname": "acme-prod-01",
"platform": "PHP 8.2 / Ubuntu 22.04",
"version": "3.4.1",
"environment": "production"
}'Response
HTTP/1.1 200 OK
X-Request-Id: 4f9a2c7b1e830d55
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 29
{
"success": true,
"data": {
"license": {
"key": "SW-A1B2C-D3E4F-5G6H7-8I9J0",
"status": "active",
"valid": true,
"message": null,
"plan_type": "subscription",
"perpetual": false,
"issued_at": "2026-03-04T10:11:02+06:00",
"expires_at": "2027-03-04T10:11:02+06:00",
"grace_ends_at": "2027-03-11T10:11:02+06:00",
"in_grace_period": false,
"days_remaining": 203
},
"product": {
"name": "DevSync Station",
"slug": "devsync-station",
"code": "SW-ERP-001",
"latest_version": "3.5.0"
},
"package": { "name": "pro", "title": "Professional" },
"entitlements": {
"tier": "pro",
"features": { "max_users": 25, "api_access": true, "white_label": false }
},
"activation": {
"instance_id": "3b1f...c9a2",
"instance_token": "8d41f0c9e6b24a7d93f5c1e0a8b6d4f27c93e150ab7d62f4e0c85b391df6a24c",
"device_name": "Head office server",
"domain": "erp.acme-corp.com",
"environment": "production",
"version": "3.4.1",
"status": "active",
"activated_at": "2026-08-13T09:22:41+06:00",
"last_seen_at": "2026-08-13T09:22:41+06:00",
"reactivated": false
},
"seats": { "max": 3, "used": 1, "unlimited": false },
"customer": { "name": "Acme Corp", "email": "[email protected]" },
"signing_secret": "5f2a...e91c",
"offline": {
"token": "eyJrIjoiU1ctQTFCMkMt....Q2ZmMg",
"valid_until": "2026-08-20T09:22:41+06:00",
"algorithm": "HMAC-SHA256"
},
"urls": {
"renewal": "https://shawonweb.com/checkout/product/devsync-station/pro",
"dashboard": "https://shawonweb.com/client/licenses",
"support": "https://shawonweb.com/contact"
},
"revalidate_after": 86400,
"server_time": "2026-08-13T09:22:41+06:00"
},
"meta": {
"api_version": "2.0",
"request_id": "4f9a2c7b1e830d55",
"timestamp": "2026-08-13T09:22:41+06:00"
}
}signing_secret and activation.instance_token are returned by
/license/activate only. Persist them at activation time — there is no endpoint
that hands them out again, though re-activating the same instance returns them afresh.
POST/license/validate
The endpoint your product lives on. Returns the same object as activate. Gate your
features on data.license.valid and nothing else — it already accounts for
revocation, suspension, expiry, grace periods and failed payments.
curl -X POST https://shawonweb.com/api/v1/license/validate \
-H "Authorization: Bearer SW-A1B2C-D3E4F-5G6H7-8I9J0" \
-H "Content-Type: application/json" \
-d '{
"product_code": "SW-ERP-001",
"instance_id": "9f2c1b7e-4d55-4a1e-9c0b-7f3a2e1d8c44",
"version": "3.4.1"
}'Two extra fields appear on this endpoint:
| Field | Meaning |
|---|---|
activation_required | true when the key is valid but this instance_id does not hold a seat — either it has never activated, or it was released and no slot was free to restore it. Call /license/activate with the key you already hold; do not prompt the user. If activate then answers activation_limit_reached, show error.details.manage_url. |
auto_reactivated | true when a previously released installation was silently restored because the licence is healthy again. Nothing for you to do; it is there so you can log it. |
Caching. Honour data.revalidate_after (86 400 s). Between
calls, trust your cached verdict. If the network is unreachable, keep running on the cache
— a reasonable policy is to allow up to 7 days offline, then fail closed.
Status values. active and past_due and
grace all carry valid: true; the last two mean you should show a
non-blocking renewal banner using urls.renewal. expired,
suspended and revoked carry valid: false.
POST/license/deactivate
seats.policy is
strict — the default — this endpoint returns 403
deactivation_not_permitted, because a self-service release would turn the
one-installation rule into "uninstall, then install anywhere". Check
seats.transferable before offering the action in your UI.
On a flexible licence it frees the seat, and is idempotent — deactivating an
already-released installation returns 200 with
already_released: true, so an uninstaller that retries never reports a false
failure.
curl -X POST https://shawonweb.com/api/v1/license/deactivate \
-H "Authorization: Bearer SW-A1B2C-D3E4F-5G6H7-8I9J0" \
-H "Content-Type: application/json" \
-d '{ "instance_id": "9f2c1b7e-4d55-4a1e-9c0b-7f3a2e1d8c44", "reason": "server decommissioned" }'POST/license/heartbeat
A small status-only response for long-running services that want to notice a revocation sooner than the daily validate. It updates last seen but never restores a released slot.
curl -X POST https://shawonweb.com/api/v1/license/heartbeat \
-H "Authorization: Bearer SW-A1B2C-D3E4F-5G6H7-8I9J0" \
-H "Content-Type: application/json" \
-d '{ "instance_id": "9f2c1b7e-4d55-4a1e-9c0b-7f3a2e1d8c44" }'{
"success": true,
"data": {
"status": "active",
"valid": true,
"message": null,
"expires_at": "2027-03-04T10:11:02+06:00",
"days_remaining": 203,
"revalidate_after": 86400,
"server_time": "2026-08-13T09:31:00+06:00"
}
}GET/license/details
Everything on record: licence, product, package, entitlements, seats, customer, billing
(subscription) and a list of every activation. Use it to build a
"Licence" screen inside your product. POST works identically if you need to
send an instance_id in a body.
curl https://shawonweb.com/api/v1/license/details \
-H "Authorization: Bearer SW-A1B2C-D3E4F-5G6H7-8I9J0"GET/license/activations
Just the seat list — handy for an admin screen that shows which machines are using the licence. Instance tokens are never included.
{
"success": true,
"data": {
"seats": { "max": 3, "used": 2, "unlimited": false },
"activations": [
{
"instance_id": "3b1f...c9a2",
"device_name": "Head office server",
"domain": "erp.acme-corp.com",
"environment": "production",
"version": "3.4.1",
"status": "active",
"activated_at": "2026-08-13T09:22:41+06:00",
"last_seen_at": "2026-08-13T09:31:00+06:00",
"released_at": null
}
]
}
}POST/license/offline-token
Returns a signed, self-contained token valid for 7 days. Your product verifies it locally
with the signing_secret it received at activation, so an installation behind a
firewall or on an intermittent connection keeps working without weakening the online
check. Activate and validate already include a fresh token in their responses, so most
products never need to call this endpoint directly.
<?php
/**
* Verify an offline token without calling the API.
* $secret is the `signing_secret` returned once at activation.
*/
function verifyOfflineToken(string $token, string $secret, string $productCode): array
{
[$body, $sig] = array_pad(explode('.', $token, 2), 2, '');
$expected = rtrim(strtr(base64_encode(
hash_hmac('sha256', $body, $secret, true)
), '+/', '-_'), '=');
if (!hash_equals($expected, $sig)) {
throw new RuntimeException('Offline token signature is invalid');
}
$claims = json_decode(base64_decode(strtr($body, '-_', '+/')), true);
if (($claims['pc'] ?? null) !== $productCode) {
throw new RuntimeException('Token was issued for a different product');
}
if (time() > ($claims['vu'] ?? 0)) {
throw new RuntimeException('Offline token has expired - reconnect to revalidate');
}
return $claims; // ['s' => status, 'v' => valid, 'tier' => ..., 'exp' => ...]
}Error codes
| Code | HTTP | What your product should do |
|---|---|---|
missing_credentials |
401 | No key was sent. Add the Authorization header. |
instance_id_required |
400 | A strict licence needs a stable instance_id on every call. Generate one per install and persist it — this is an integration bug, not a customer problem. |
invalid_license_key |
401 | The key does not exist. Ask the customer to re-copy it from their dashboard. |
license_already_activated |
409 | The key is already in use on another installation. Show error.details.support_url — the customer must contact support to have it reopened. Do not retry. |
activation_revoked |
403 | This installation was retired by support. The replacement machine should activate instead. Do not retry on this machine. |
deactivation_not_permitted |
403 | The licence is permanently bound and cannot be released by the product. Remove your deactivate call, or hide the button. |
license_suspended |
403 | Suspended by an administrator. Stop the product and point the user at support. |
license_revoked |
403 | Permanently revoked. Do not retry. |
license_expired |
403 | Past its expiry and past the grace period. Show the renewal URL from error.details. |
product_mismatch |
403 | The key belongs to a different product. Reject it in your activation UI. |
domain_not_allowed |
403 | The licence is locked to another domain. |
activation_limit_reached |
409 | All seats are in use on a flexible licence. Show error.details.manage_url so the user can free one. |
instance_not_found |
404 | Nothing to deactivate for this instance_id. |
not_activated |
403 | An offline token was requested before activation. |
rate_limited |
429 | Back off for the number of seconds in the Retry-After header. |
server_error |
500 | Our side failed. Retry with backoff and quote meta.request_id if it persists. |
HTTP/1.1 409 Conflict
{
"success": false,
"error": {
"code": "activation_limit_reached",
"message": "This licence has reached its activation limit. Deactivate an existing installation, or upgrade the plan for more seats.",
"details": {
"max_activations": 3,
"used_activations": 3,
"manage_url": "https://shawonweb.com/client/licenses"
}
},
"meta": {
"api_version": "2.0",
"request_id": "9c02fe41a7b3d688",
"endpoint": "activate",
"timestamp": "2026-08-13T09:24:10+06:00",
"docs": "https://shawonweb.com/docs/license-api"
}
}Rate limits
Limits are per licence, per endpoint, in a fixed one-hour window. A separate per-IP limit
guards unauthenticated traffic so nobody can brute-force keys. Every response carries
X-RateLimit-Limit, X-RateLimit-Remaining and
X-RateLimit-Reset; a 429 also carries Retry-After.
| Endpoint | Limit per licence |
|---|---|
/license/validate | 240 / hour |
/license/heartbeat | 120 / hour |
/license/details | 120 / hour |
/license/activations | 60 / hour |
/license/activate, /license/deactivate | 30 / hour |
/license/offline-token | 24 / hour |
A correctly built integration uses a handful of calls a day. If you are anywhere near
these numbers you are almost certainly not caching revalidate_after.
Webhooks
Set a webhook URL on the licence from
your dashboard
and we will POST license.activated and license.deactivated events
to it. Each request is signed with the licence's signing secret in
X-ShawonWeb-Signature. Only public HTTPS endpoints are called.
<?php
// Verify an incoming Shawon Web webhook.
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SHAWONWEB_SIGNATURE'] ?? ''; // "sha256=<hex>"
$expected = 'sha256=' . hash_hmac('sha256', $payload, $signingSecret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('bad signature');
}
$event = json_decode($payload, true);
// $event['event'] => license.activated | license.deactivatedClient libraries
Complete, copy-paste clients. Each one implements the whole lifecycle: activate once, cache for a day, tolerate being offline, and re-activate silently when the licence comes back to life.
<?php
/**
* Minimal Shawon Web licence client. Drop into any PHP product.
*
* $lic = new ShawonLicense('SW-ERP-001', __DIR__ . '/storage/license.json');
* if (!$lic->isActive()) { header('Location: /activate.php'); exit; }
*/
final class ShawonLicense
{
private const BASE = 'https://shawonweb.com/api/v1';
/** Last failure message, for your "licence problem" screen. */
public ?string $lastError = null;
public function __construct(
private string $productCode,
private string $storePath
) {}
/** Called once, from your activation screen. */
public function activate(string $licenseKey): array
{
$res = $this->call('POST', '/license/activate', $licenseKey, [
'product_code' => $this->productCode,
'instance_id' => $this->instanceId(),
'device_name' => gethostname(),
'domain' => $_SERVER['HTTP_HOST'] ?? null,
'hostname' => gethostname(),
'platform' => 'PHP ' . PHP_VERSION . ' / ' . PHP_OS,
'version' => APP_VERSION,
'environment' => 'production',
]);
if (!($res['success'] ?? false)) {
throw new RuntimeException($res['error']['message'] ?? 'Activation failed');
}
// Persist the key. From here on the product re-checks by itself and the
// customer never types the key again -- not after a renewal, not after
// a re-purchase, not after a restart.
$this->save([
'license_key' => $licenseKey,
'instance_token' => $res['data']['activation']['instance_token'],
'signing_secret' => $res['data']['signing_secret'],
'state' => $res['data'],
'checked_at' => time(),
]);
return $res['data'];
}
/** Called on every boot. Cheap: it only hits the network once a day. */
public function isActive(): bool
{
$store = $this->load();
if (empty($store['license_key'])) {
return false;
}
$age = time() - (int)($store['checked_at'] ?? 0);
$ttl = (int)($store['state']['revalidate_after'] ?? 86400);
if ($age < $ttl) {
return (bool)($store['state']['license']['valid'] ?? false);
}
try {
$res = $this->call('POST', '/license/validate', $store['license_key'], [
'product_code' => $this->productCode,
'instance_id' => $this->instanceId(),
'version' => APP_VERSION,
]);
} catch (Throwable $e) {
// Offline: keep running until the cached result goes stale enough
// to be untrustworthy, then fail closed.
return $age < 7 * 86400 && ($store['state']['license']['valid'] ?? false);
}
// The key is valid but this machine holds no slot. Re-activate with the
// key we already have -- never re-prompt the user. If the licence is
// already claimed by another machine this fails, and it should: that is
// the one-installation rule doing its job.
if ($res['data']['activation_required'] ?? false) {
try {
$this->activate($store['license_key']);
return true;
} catch (Throwable $e) {
$this->lastError = $e->getMessage(); // show this, with the support link
return false;
}
}
$store['state'] = $res['data'];
$store['checked_at'] = time();
$this->save($store);
return (bool)($res['data']['license']['valid'] ?? false);
}
/** True only on flexible (multi-seat) licences. */
public function canDeactivate(): bool
{
return (bool)($this->load()['state']['seats']['transferable'] ?? false);
}
public function deactivate(): void
{
$store = $this->load();
if (empty($store['license_key'])) {
return;
}
// A strict licence is permanently bound; the endpoint would return
// deactivation_not_permitted. Hide the button instead of calling it.
if (!$this->canDeactivate()) {
throw new RuntimeException(
'This licence is permanently bound to this installation. '
. 'Contact Shawon Web support to move it to another machine.'
);
}
$this->call('POST', '/license/deactivate', $store['license_key'], [
'instance_id' => $this->instanceId(),
'reason' => 'uninstall',
]);
@unlink($this->storePath);
}
public function feature(string $name, $default = null)
{
return $this->load()['state']['entitlements']['features'][$name] ?? $default;
}
/** Stable per-installation id. Generated once, then reused forever. */
private function instanceId(): string
{
$store = $this->load();
if (!empty($store['instance_id'])) {
return $store['instance_id'];
}
$store['instance_id'] = bin2hex(random_bytes(16));
$this->save($store);
return $store['instance_id'];
}
private function call(string $method, string $path, string $key, array $body): array
{
$ch = curl_init(self::BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
]);
$raw = curl_exec($ch);
if ($raw === false) {
throw new RuntimeException('Licence server unreachable: ' . curl_error($ch));
}
curl_close($ch);
return json_decode($raw, true) ?: [];
}
private function load(): array
{
return is_file($this->storePath)
? (json_decode(file_get_contents($this->storePath), true) ?: [])
: [];
}
private function save(array $data): void
{
@mkdir(dirname($this->storePath), 0750, true);
file_put_contents($this->storePath, json_encode($data), LOCK_EX);
@chmod($this->storePath, 0600);
}
}using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
// Shawon Web licence client for .NET desktop / service products.
public sealed class ShawonLicense
{
private const string Base = "https://shawonweb.com/api/v1";
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(15) };
private readonly string _productCode;
private readonly string _storePath;
public ShawonLicense(string productCode, string storePath)
{
_productCode = productCode;
_storePath = storePath;
}
public async Task<JsonElement> ActivateAsync(string licenseKey)
{
var res = await CallAsync("/license/activate", licenseKey, new
{
product_code = _productCode,
instance_id = InstanceId(),
device_name = Environment.MachineName,
hostname = Environment.MachineName,
platform = $".NET {Environment.Version} / {Environment.OSVersion}",
version = typeof(ShawonLicense).Assembly.GetName().Version?.ToString(),
environment = "production"
});
if (!res.GetProperty("success").GetBoolean())
throw new InvalidOperationException(res.GetProperty("error").GetProperty("message").GetString());
var data = res.GetProperty("data");
SaveStore(new Store
{
LicenseKey = licenseKey,
InstanceId = InstanceId(),
InstanceToken = data.GetProperty("activation").GetProperty("instance_token").GetString(),
SigningSecret = data.GetProperty("signing_secret").GetString(),
StateJson = data.GetRawText(),
CheckedAt = DateTimeOffset.UtcNow
});
return data;
}
/// Call at startup. Hits the network at most once a day.
public async Task<bool> IsActiveAsync()
{
var store = LoadStore();
if (store?.LicenseKey is null) return false;
var cached = JsonDocument.Parse(store.StateJson!).RootElement;
var ttl = cached.GetProperty("revalidate_after").GetInt32();
var age = (DateTimeOffset.UtcNow - store.CheckedAt).TotalSeconds;
if (age < ttl)
return cached.GetProperty("license").GetProperty("valid").GetBoolean();
JsonElement res;
try
{
res = await CallAsync("/license/validate", store.LicenseKey, new
{
product_code = _productCode,
instance_id = store.InstanceId
});
}
catch (HttpRequestException)
{
// Offline tolerance: trust the cached verdict for up to 7 days.
return age < TimeSpan.FromDays(7).TotalSeconds
&& cached.GetProperty("license").GetProperty("valid").GetBoolean();
}
var data = res.GetProperty("data");
// Valid key but no slot on this machine: re-activate with the key we
// already hold, never re-prompt. If another machine owns the licence
// this throws -- show the message, it carries the support link.
if (data.TryGetProperty("activation_required", out var need) && need.GetBoolean())
{
try
{
await ActivateAsync(store.LicenseKey);
return true;
}
catch (InvalidOperationException ex)
{
LastError = ex.Message;
return false;
}
}
store.StateJson = data.GetRawText();
store.CheckedAt = DateTimeOffset.UtcNow;
SaveStore(store);
return data.GetProperty("license").GetProperty("valid").GetBoolean();
}
private async Task<JsonElement> CallAsync(string path, string key, object body)
{
using var req = new HttpRequestMessage(HttpMethod.Post, Base + path)
{
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json")
};
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", key);
using var resp = await Http.SendAsync(req);
var text = await resp.Content.ReadAsStringAsync();
return JsonDocument.Parse(text).RootElement.Clone();
}
// InstanceId(), LoadStore() and SaveStore() persist a small JSON file next
// to the app (or a registry key). The instance id is generated once with
// Guid.NewGuid() and must never change for the life of the install.
}// Shawon Web licence client for Node / Electron products.
import { randomUUID } from 'node:crypto';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import os from 'node:os';
const BASE = 'https://shawonweb.com/api/v1';
export class ShawonLicense {
constructor(productCode, storePath) {
this.productCode = productCode;
this.storePath = storePath;
}
async activate(licenseKey) {
const data = await this.#call('/license/activate', licenseKey, {
product_code: this.productCode,
instance_id: this.#instanceId(),
device_name: os.hostname(),
hostname: os.hostname(),
platform: `Node ${process.version} / ${os.platform()} ${os.release()}`,
version: process.env.APP_VERSION,
environment: 'production',
});
this.#save({
license_key: licenseKey,
instance_id: this.#instanceId(),
instance_token: data.activation.instance_token,
signing_secret: data.signing_secret,
state: data,
checked_at: Date.now(),
});
return data;
}
/** Call on every launch. */
async isActive() {
const store = this.#load();
if (!store.license_key) return false;
const ageMs = Date.now() - (store.checked_at ?? 0);
const ttlMs = (store.state?.revalidate_after ?? 86400) * 1000;
if (ageMs < ttlMs) return store.state?.license?.valid === true;
let data;
try {
data = await this.#call('/license/validate', store.license_key, {
product_code: this.productCode,
instance_id: store.instance_id,
version: process.env.APP_VERSION,
});
} catch {
// Offline grace: keep the last verdict for a week, then fail closed.
return ageMs < 7 * 86400_000 && store.state?.license?.valid === true;
}
// Valid key, no slot on this machine: re-activate with the key we already
// hold, never re-prompt. Failure here means another machine owns the
// licence -- surface the message, it carries the support link.
if (data.activation_required) {
try {
await this.activate(store.license_key);
return true;
} catch (e) {
this.lastError = e.message;
return false;
}
}
this.#save({ ...store, state: data, checked_at: Date.now() });
return data.license.valid === true;
}
/** True only on flexible (multi-seat) licences. */
canDeactivate() {
return this.#load().state?.seats?.transferable === true;
}
async deactivate() {
const store = this.#load();
if (!store.license_key) return;
// A strict licence is permanently bound and the endpoint would refuse.
// Gate your uninstall UI on this rather than calling and catching.
if (!this.canDeactivate()) {
throw new Error(
'This licence is permanently bound to this installation. ' +
'Contact Shawon Web support to move it to another machine.'
);
}
await this.#call('/license/deactivate', store.license_key, {
instance_id: store.instance_id,
reason: 'uninstall',
});
}
feature(name, fallback = null) {
return this.#load().state?.entitlements?.features?.[name] ?? fallback;
}
async #call(path, key, body) {
const r = await fetch(BASE + path, {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await r.json();
if (!json.success) throw new Error(`${json.error.code}: ${json.error.message}`);
return json.data;
}
#instanceId() {
const store = this.#load();
if (store.instance_id) return store.instance_id;
store.instance_id = randomUUID();
this.#save(store);
return store.instance_id;
}
#load() {
return existsSync(this.storePath) ? JSON.parse(readFileSync(this.storePath, 'utf8')) : {};
}
#save(data) {
writeFileSync(this.storePath, JSON.stringify(data), { mode: 0o600 });
}
}"""Shawon Web licence client for Python products."""
import json, os, platform, socket, time, uuid
from pathlib import Path
import requests
BASE = "https://shawonweb.com/api/v1"
class ShawonLicense:
def __init__(self, product_code: str, store_path: str):
self.product_code = product_code
self.store = Path(store_path)
# -- public ----------------------------------------------------------
def activate(self, license_key: str) -> dict:
data = self._call("/license/activate", license_key, {
"product_code": self.product_code,
"instance_id": self._instance_id(),
"device_name": socket.gethostname(),
"hostname": socket.gethostname(),
"platform": f"Python {platform.python_version()} / {platform.system()}",
"version": os.getenv("APP_VERSION"),
"environment": "production",
})
self._save({
"license_key": license_key,
"instance_id": self._instance_id(),
"instance_token": data["activation"]["instance_token"],
"signing_secret": data["signing_secret"],
"state": data,
"checked_at": time.time(),
})
return data
def is_active(self) -> bool:
store = self._load()
if not store.get("license_key"):
return False
age = time.time() - store.get("checked_at", 0)
ttl = store.get("state", {}).get("revalidate_after", 86400)
if age < ttl:
return bool(store["state"]["license"]["valid"])
try:
data = self._call("/license/validate", store["license_key"], {
"product_code": self.product_code,
"instance_id": store["instance_id"],
})
except requests.RequestException:
# Offline: trust the cached verdict for up to a week.
return age < 7 * 86400 and bool(store["state"]["license"]["valid"])
# Valid key, no slot on this machine: re-activate with the key we already
# hold, never re-prompt. A RuntimeError here means another machine owns
# the licence -- show it, the message carries the support link.
if data.get("activation_required"):
try:
self.activate(store["license_key"])
return True
except RuntimeError as exc:
self.last_error = str(exc)
return False
store["state"], store["checked_at"] = data, time.time()
self._save(store)
return bool(data["license"]["valid"])
def feature(self, name, default=None):
return self._load().get("state", {}).get("entitlements", {}).get("features", {}).get(name, default)
# -- internals -------------------------------------------------------
def _call(self, path: str, key: str, body: dict) -> dict:
r = requests.post(
BASE + path,
json=body,
headers={"Authorization": f"Bearer {key}"},
timeout=15,
)
payload = r.json()
if not payload.get("success"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
def _instance_id(self) -> str:
store = self._load()
if not store.get("instance_id"):
store["instance_id"] = str(uuid.uuid4())
self._save(store)
return store["instance_id"]
def _load(self) -> dict:
return json.loads(self.store.read_text()) if self.store.exists() else {}
def _save(self, data: dict) -> None:
self.store.parent.mkdir(parents=True, exist_ok=True)
self.store.write_text(json.dumps(data))
os.chmod(self.store, 0o600)Integration checklist
-
Generate an
instance_idonce per installation and never change it. -
Send
product_codeon every call so another product's key can never activate yours. - Store the licence key locally after the first successful activation.
-
Gate features on
data.license.valid, not onstatusstrings. -
Cache the validate response for
revalidate_afterseconds. -
Handle
activation_required: trueby re-calling activate with the stored key — never by prompting the user. -
Show a non-blocking renewal banner for
graceandpast_due, usingurls.renewal. -
Treat
license_already_activatedas final: showerror.details.support_urland stop. Never retry it in a loop or with a freshinstance_id. -
Check
seats.transferablebefore showing any "deactivate" or "move licence" action — on a strict licence there is none. - Keep working for a bounded period when the API is unreachable, then fail closed.
- Never ship the licence key in client-side JavaScript or a public repository.
Migrating from the old API
The previous endpoints under /api/license/* required an HMAC signature over a
timestamp and a separate secret, and licences were bound to a single domain. Those
endpoints still work and existing installs keep running — but they are frozen, and new
integrations should use /api/v1.
| Old | New |
|---|---|
POST /api/license/verify | POST /api/v1/license/validate |
POST /api/license/validate | POST /api/v1/license/validate |
POST /api/license/status | POST /api/v1/license/heartbeat |
POST /api/license/bind | POST /api/v1/license/activate |
POST /api/license/unbind | POST /api/v1/license/deactivate |
| HMAC signature + timestamp | Authorization: Bearer <license_key> |
| One domain per licence | Seats, each keyed on an instance_id |
| Direct database access from the product | No longer needed, or supported, for licensing |
Licences already bound to a domain were migrated into an activation automatically, so those installs did not need to be re-activated.
Need a hand integrating?
Quote your X-Request-Id
and the product code, and we can trace any call end to end.