Standalone FastAPI service that issues, verifies, and revokes license keys for EnterpriseCore AI Suite (and any other product you point at it).
| Method | Path | Purpose |
|---|---|---|
| GET | /api/health |
Liveness |
| POST | /api/v1/licenses/issue |
Issue a new key (admin / webhook) |
| POST | /api/v1/licenses/verify |
Desktop client checks a key + machine |
| POST | /api/v1/licenses/revoke |
Revoke a key (refund / abuse) |
| POST | /api/v1/licenses/deactivate |
Customer removes a machine slot |
| POST | /api/v1/webhooks/lemon-squeezy |
Lemon Squeezy order_created / order_refunded |
python -m venv .venv && .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
alembic upgrade head
uvicorn app.main:app --reload --port 8765Open http://localhost:8765/docs for the OpenAPI explorer.
pytest -qfly launch # first time only — accept the existing fly.toml
fly secrets set \
LEMON_SQUEEZY_WEBHOOK_SECRET=... \
RESEND_API_KEY=... \
LICENSE_SECRET_KEY=$(openssl rand -base64 32)
fly deployDATABASE_URL is auto-injected by Fly if you attach a Postgres app:
fly postgres create --name ec-license-pg --region sin
fly postgres attach --app ec-license-server ec-license-pg- Create a store + product in https://app.lemonsqueezy.com
- Settings → Webhooks → Add endpoint
- URL:
https://ec-license-server.fly.dev/api/v1/webhooks/lemon-squeezy - Signing secret: copy → put into
LEMON_SQUEEZY_WEBHOOK_SECRET - Events:
order_created,order_refunded
- URL:
- Test with the "Send test event" button → check that
webhook_eventstable has a row withprocessed=true
# EnterpriseCore/backend/app/core/license_key.py
import httpx, uuid, json, datetime
from pathlib import Path
LICENSE_API = "https://ec-license-server.fly.dev/api/v1/licenses"
CACHE_FILE = Path.home() / ".enterprisecore" / "license.json"
def verify(key: str, machine_id: str) -> dict:
# Try remote first, fall back to local cache if offline
try:
r = httpx.post(f"{LICENSE_API}/verify",
json={"key": key, "machine_id": machine_id, "app_version": "0.1.0"},
timeout=8)
if r.status_code == 200:
body = r.json()
if body["valid"]:
CACHE_FILE.parent.mkdir(exist_ok=True, parents=True)
CACHE_FILE.write_text(json.dumps({
"key": key, "verified_at": datetime.datetime.utcnow().isoformat(),
"renewal_days": body["renewal_days"], "tier": body["tier"],
}))
return body
except httpx.HTTPError:
pass
# Offline path — accept cache if within renewal window
if CACHE_FILE.exists():
cached = json.loads(CACHE_FILE.read_text())
if cached["key"] == key:
verified = datetime.datetime.fromisoformat(cached["verified_at"])
age_days = (datetime.datetime.utcnow() - verified).days
if age_days <= cached["renewal_days"]:
return {"valid": True, "tier": cached["tier"], "offline": True}
return {"valid": False, "reason": "offline_no_cache"}Format: EC-XXXX-XXXX-XXXX-XXXX — base32 alphabet without ambiguous characters (no 0/O/I/L/1).
Default policy: 3 machines per key, 7-day offline grace period.