API Docs

Everything you need to integrate vshll licensing and push live updates to your users.

How auto-updates work

  1. You ship v1.0.0 to your users as an .exe.
  2. A month later you upload v1.2.0 from the dashboard and mark it active.
  3. When the user opens their old exe, it calls /api/public/v1/update.
  4. The server compares versions and returns a signed 5-minute download URL.
  5. Your updater downloads it, verifies the SHA-256, replaces the binary, and relaunches.

Tracking the installed version (stop re-downloading)

If your app redownloads on every launch, it's because current_version never changes — a hardcoded constant in the new exe is still whatever you compiled it as. The server only skips the download when the version you send matches the latest active version.

Fix: store the installed version in a tiny version.txt next to the exe. Read it at startup, write the new value right after a successful update. Bonus: you no longer need to rebuild with a different constant for every release.

Python — version helper

python
# version.py — read/write the installed version next to the exe
import os, sys

def _version_file():
    base = os.path.dirname(sys.executable if getattr(sys, "frozen", False) else __file__)
    return os.path.join(base, "version.txt")

FALLBACK_VERSION = "1.0.0"  # only used the very first run

def get_current_version():
    try:
        with open(_version_file(), "r") as f:
            return f.read().strip() or FALLBACK_VERSION
    except FileNotFoundError:
        return FALLBACK_VERSION

def set_current_version(v):
    with open(_version_file(), "w") as f:
        f.write(v.strip())

Use it in your updater:

python
from version import get_current_version, set_current_version

CURRENT_VERSION = get_current_version()

# inside your updater worker, AFTER the .bat is written and BEFORE relaunch:
set_current_version(info["latest_version"])

C++ / Qt — version helper

cpp
// version.hpp
#pragma once
#include <QCoreApplication>
#include <QFile>
#include <QFileInfo>
#include <QString>

inline QString versionFilePath() {
    return QFileInfo(QCoreApplication::applicationFilePath()).absolutePath() + "/version.txt";
}
inline QString getCurrentVersion(const QString& fallback = "1.0.0") {
    QFile f(versionFilePath());
    if (!f.open(QIODevice::ReadOnly)) return fallback;
    QString v = QString::fromUtf8(f.readAll()).trimmed();
    return v.isEmpty() ? fallback : v;
}
inline void setCurrentVersion(const QString& v) {
    QFile f(versionFilePath());
    if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) f.write(v.trimmed().toUtf8());
}

Then replace the hardcoded CURRENT_VERSION with getCurrentVersion(), and call setCurrentVersion(latest) after the download verifies but before the relaunch.

C# / .NET — version helper

csharp
// VersionFile.cs — read/write the installed version next to the exe
using System;
using System.IO;

public static class VersionFile {
    public const string Fallback = "1.0.0";
    private static string Path =>
        System.IO.Path.Combine(AppContext.BaseDirectory, "version.txt");

    public static string Get() {
        try { var v = File.ReadAllText(Path).Trim();
              return string.IsNullOrEmpty(v) ? Fallback : v; }
        catch (FileNotFoundException) { return Fallback; }
    }
    public static void Set(string v) => File.WriteAllText(Path, v.Trim());
}

Call VersionFile.Get() when building your update-check request. You don't need to call VersionFile.Set() yourself — the updater's swap script writes version.txt atomically after the new binary is in place.

Heads up: write version.txt only after the SHA-256 check passes and the new binary is in place — otherwise a failed update will convince your app it's already on the new version and you'll be stuck.

POST /api/public/v1/update

Request body:

json
{
  "api_key": "app_xxx",
  "license_key": "XXXX-XXXX-XXXX-XXXX-XXXX",
  "hwid": "device-fingerprint",
  "current_version": "1.0.0"
}

Response:

json
{
  "update_available": true,
  "latest_version": "1.2.0",
  "current_version": "1.0.0",
  "mandatory": false,
  "notes": "Bug fixes and new UI",
  "file_size": 8421376,
  "sha256": "a1b2c3...",
  "download_url": "https://...signed-url...?exp=..."
}

Python updater (Tkinter + progress bar)

Drop this into your app and call maybe_update(license_key, hwid) at startup.

python
# pip install requests
# Assumes you also have version.py from the section above
import os, sys, hashlib, tempfile, subprocess, threading, requests
import tkinter as tk
from tkinter import ttk
from version import get_current_version, set_current_version

API = "https://vshll.lol/api/public/v1"
API_KEY = "YOUR_APP_API_KEY"

def check_for_update(license_key, hwid):
    r = requests.post(f"{API}/update", json={
        "api_key": API_KEY,
        "license_key": license_key,
        "hwid": hwid,
        "current_version": get_current_version(),
    }, timeout=15)
    r.raise_for_status()
    return r.json()

def download_with_progress(url, dest, sha256, on_progress):
    h = hashlib.sha256()
    with requests.get(url, stream=True, timeout=60) as r:
        r.raise_for_status()
        total = int(r.headers.get("content-length", 0))
        done = 0
        with open(dest, "wb") as f:
            for chunk in r.iter_content(1024 * 64):
                f.write(chunk); h.update(chunk); done += len(chunk)
                if total: on_progress(done / total)
    if sha256 and h.hexdigest() != sha256:
        raise RuntimeError("Checksum mismatch — file tampered")

def show_updater(latest, url, sha256):
    root = tk.Tk()
    root.title("Updating vshll App")
    root.geometry("380x140"); root.configure(bg="#0a0a0a")
    tk.Label(root, text=f"Updating to v{latest}…", fg="white", bg="#0a0a0a",
             font=("Segoe UI", 11, "bold")).pack(pady=(20, 8))
    status = tk.Label(root, text="Downloading…", fg="#888", bg="#0a0a0a")
    status.pack()
    bar = ttk.Progressbar(root, length=320, mode="determinate", maximum=100)
    bar.pack(pady=14)

    tmp = os.path.join(tempfile.gettempdir(), "vshll_update.exe")

    def worker():
        try:
            download_with_progress(url, tmp, sha256,
                lambda p: (bar.config(value=p*100), root.update_idletasks()))
            status.config(text="Installing…")
            # Atomic swap with backup + rollback. version.txt is written by the
            # .bat ONLY after the move succeeds — never trust a half-applied update.
            current = sys.executable
            backup  = current + ".bak"
            vfile   = os.path.join(os.path.dirname(current), "version.txt")
            pid     = os.getpid()
            bat     = current + ".update.bat"
            with open(bat, "w") as f:
                f.write(
                    f'@echo off\n'
                    f'setlocal\n'
                    f':wait\n'
                    f'tasklist /FI "PID eq {pid}" 2>NUL | find "{pid}" >NUL\n'
                    f'if not errorlevel 1 (timeout /t 1 /nobreak >NUL & goto wait)\n'
                    f'if exist "{backup}" del /F /Q "{backup}"\n'
                    f'move /Y "{current}" "{backup}" || (timeout /t 2 /nobreak >NUL & move /Y "{current}" "{backup}")\n'
                    f'move /Y "{tmp}" "{current}"\n'
                    f'if errorlevel 1 (echo rollback & move /Y "{backup}" "{current}" & start "" "{current}" & del "%%~f0" & exit /b 1)\n'
                    f'> "{vfile}" echo {latest}\n'
                    f'del /F /Q "{backup}" 2>NUL\n'
                    f'start "" "{current}"\n'
                    f'del "%%~f0"\n'
                )
            subprocess.Popen(["cmd", "/c", bat],
                creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
            root.destroy(); sys.exit(0)
        except Exception as e:
            status.config(text=f"Update failed: {e}")
    threading.Thread(target=worker, daemon=True).start()
    root.mainloop()

# Call this at app startup, BEFORE your main UI
def maybe_update(license_key, hwid):
    info = check_for_update(license_key, hwid)
    if info.get("update_available"):
        show_updater(info["latest_version"], info["download_url"], info.get("sha256"))

C++ updater (Qt6 + progress bar)

Requires Qt 6 (Widgets + Network). Show Updater before your main window.

cpp
// C++17 + Qt 6 (Widgets + Network). Link: Qt6::Widgets Qt6::Network
#include <QApplication>
#include <QProgressBar>
#include <QLabel>
#include <QVBoxLayout>
#include <QWidget>
#include <QNetworkAccessManager>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QJsonDocument>
#include <QJsonObject>
#include <QCryptographicHash>
#include <QFile>
#include <QProcess>
#include <QStandardPaths>
#include "version.hpp"

static const QString API_BASE = "https://vshll.lol/api/public/v1";
static const QString API_KEY  = "YOUR_APP_API_KEY";

class Updater : public QWidget {
    Q_OBJECT
public:
    Updater(QString licenseKey, QString hwid) {
        setWindowTitle("Updating vshll App");
        resize(380, 140);
        auto* lay = new QVBoxLayout(this);
        statusLbl = new QLabel("Checking for updates…");
        bar = new QProgressBar(); bar->setRange(0, 100);
        lay->addWidget(statusLbl); lay->addWidget(bar);
        checkUpdate(licenseKey, hwid);
    }
private:
    QNetworkAccessManager nm;
    QLabel* statusLbl; QProgressBar* bar;
    QByteArray expectedSha;
    QString latestVersion;

    void checkUpdate(const QString& key, const QString& hwid) {
        QJsonObject body{{"api_key", API_KEY}, {"license_key", key},
                         {"hwid", hwid}, {"current_version", getCurrentVersion()}};
        QNetworkRequest req(QUrl(API_BASE + "/update"));
        req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
        auto* reply = nm.post(req, QJsonDocument(body).toJson());
        connect(reply, &QNetworkReply::finished, [this, reply]{
            auto json = QJsonDocument::fromJson(reply->readAll()).object();
            reply->deleteLater();
            if (!json.value("update_available").toBool()) { close(); return; }
            expectedSha = json.value("sha256").toString().toUtf8();
            latestVersion = json.value("latest_version").toString();
            statusLbl->setText("Downloading v" + latestVersion);
            download(json.value("download_url").toString());
        });
    }

    void download(const QString& url) {
        auto* reply = nm.get(QNetworkRequest(QUrl(url)));
        auto* tmp = new QFile(QStandardPaths::writableLocation(
            QStandardPaths::TempLocation) + "/vshll_update.exe", this);
        tmp->open(QIODevice::WriteOnly);
        QCryptographicHash* h = new QCryptographicHash(QCryptographicHash::Sha256);
        connect(reply, &QNetworkReply::downloadProgress, [this](qint64 a, qint64 b){
            if (b > 0) bar->setValue(int(100 * a / b));
        });
        connect(reply, &QNetworkReply::readyRead, [reply, tmp, h]{
            auto data = reply->readAll(); tmp->write(data); h->addData(data);
        });
        connect(reply, &QNetworkReply::finished, [this, reply, tmp, h]{
            tmp->close(); reply->deleteLater();
            if (!expectedSha.isEmpty() && h->result().toHex() != expectedSha) {
                statusLbl->setText("Checksum failed — aborting."); delete h; return;
            }
            delete h;
            statusLbl->setText("Installing…");
            installAndRelaunch(tmp->fileName());
        });
    }

    void installAndRelaunch(const QString& newExe) {
        QString cur = QCoreApplication::applicationFilePath();
        QString bat = cur + ".update.bat";
        qint64 pid = QCoreApplication::applicationPid();
        QString backup = cur + ".bak";
        QString vfile  = QFileInfo(cur).absolutePath() + "/version.txt";
        QFile f(bat); f.open(QIODevice::WriteOnly);
        // Backup + atomic swap + rollback. version.txt is written ONLY on success.
        f.write(QString(
            "@echo off\n"
            "setlocal\n"
            ":wait\n"
            "tasklist /FI \"PID eq %5\" 2>NUL | find \"%5\" >NUL\n"
            "if not errorlevel 1 (timeout /t 1 /nobreak >NUL & goto wait)\n"
            "if exist \"%3\" del /F /Q \"%3\"\n"
            "move /Y \"%2\" \"%3\" || (timeout /t 2 /nobreak >NUL & move /Y \"%2\" \"%3\")\n"
            "move /Y \"%1\" \"%2\"\n"
            "if errorlevel 1 (move /Y \"%3\" \"%2\" & start \"\" \"%2\" & del \"%%~f0\" & exit /b 1)\n"
            "> \"%4\" echo %6\n"
            "del /F /Q \"%3\" 2>NUL\n"
            "start \"\" \"%2\"\n"
            "del \"%%~f0\"\n"
        ).arg(newExe, cur, backup, vfile, QString::number(pid), latestVersion).toUtf8());
        f.close();
        QProcess::startDetached("cmd", {"/c", bat});
        QCoreApplication::quit();
    }
};

// In main(): Updater u(licenseKey, hwid); u.show();  // BEFORE launching main window

C# updater (.NET 6+ WPF + progress bar)

Built-in HttpClient + WPF — no NuGet dependencies. Call await Updater.RunAsync(licenseKey, hwid) in App.OnStartup before showing your main window.

csharp
// .NET 6+ WPF (or WinForms). NuGet: none — uses built-in HttpClient + WPF.
// Show UpdaterWindow at startup BEFORE your MainWindow.
using System;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using System.Windows;

public record UpdateResp(
    [property: JsonPropertyName("update_available")] bool UpdateAvailable,
    [property: JsonPropertyName("latest_version")]   string? LatestVersion,
    [property: JsonPropertyName("download_url")]     string? DownloadUrl,
    [property: JsonPropertyName("sha256")]           string? Sha256,
    [property: JsonPropertyName("mandatory")]        bool Mandatory);

public class Updater {
    const string API = "https://vshll.lol/api/public/v1";
    const string API_KEY = "YOUR_APP_API_KEY";
    static readonly HttpClient http = new() { Timeout = TimeSpan.FromMinutes(5) };

    public static async Task RunAsync(string licenseKey, string hwid) {
        var body = new {
            api_key = API_KEY, license_key = licenseKey, hwid,
            current_version = VersionFile.Get()
        };
        var resp = await http.PostAsJsonAsync($"{API}/update", body);
        resp.EnsureSuccessStatusCode();
        var info = await resp.Content.ReadFromJsonAsync<UpdateResp>();
        if (info is null || !info.UpdateAvailable) return;

        var win = new UpdaterWindow(info.LatestVersion!);
        win.Show();
        try {
            var tmp = Path.Combine(Path.GetTempPath(), "vshll_update.exe");
            await DownloadAsync(info.DownloadUrl!, tmp, info.Sha256, win.SetProgress);
            win.SetStatus("Installing…");
            InstallAndRelaunch(tmp, info.LatestVersion!);
            Application.Current.Shutdown();
        } catch (Exception e) {
            win.SetStatus("Update failed: " + e.Message);
            if (info.Mandatory) Application.Current.Shutdown();
        }
    }

    static async Task DownloadAsync(string url, string dest, string? sha, Action<double> onProgress) {
        using var r = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
        r.EnsureSuccessStatusCode();
        var total = r.Content.Headers.ContentLength ?? 0L;
        await using var src = await r.Content.ReadAsStreamAsync();
        await using var dst = File.Create(dest);
        using var sha256 = SHA256.Create();
        var buf = new byte[64 * 1024]; long done = 0; int n;
        while ((n = await src.ReadAsync(buf)) > 0) {
            await dst.WriteAsync(buf.AsMemory(0, n));
            sha256.TransformBlock(buf, 0, n, null, 0); done += n;
            if (total > 0) onProgress((double)done / total);
        }
        sha256.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
        var got = Convert.ToHexString(sha256.Hash!).ToLowerInvariant();
        if (!string.IsNullOrEmpty(sha) && !string.Equals(got, sha, StringComparison.OrdinalIgnoreCase))
            throw new InvalidDataException("Checksum mismatch — file tampered");
    }

    static void InstallAndRelaunch(string newExe, string latest) {
        var cur = Process.GetCurrentProcess().MainModule!.FileName!;
        var backup = cur + ".bak";
        var vfile  = Path.Combine(Path.GetDirectoryName(cur)!, "version.txt");
        var pid    = Environment.ProcessId;
        var bat    = cur + ".update.bat";
        // Backup + atomic swap + rollback. version.txt written ONLY on success.
        File.WriteAllText(bat, $@"@echo off
setlocal
:wait
tasklist /FI ""PID eq {pid}"" 2>NUL | find ""{pid}"" >NUL
if not errorlevel 1 (timeout /t 1 /nobreak >NUL & goto wait)
if exist ""{backup}"" del /F /Q ""{backup}""
move /Y ""{cur}"" ""{backup}"" || (timeout /t 2 /nobreak >NUL & move /Y ""{cur}"" ""{backup}"")
move /Y ""{newExe}"" ""{cur}""
if errorlevel 1 (move /Y ""{backup}"" ""{cur}"" & start """" ""{cur}"" & del ""%~f0"" & exit /b 1)
> ""{vfile}"" echo {latest}
del /F /Q ""{backup}"" 2>NUL
start """" ""{cur}""
del ""%~f0""
");
        Process.Start(new ProcessStartInfo("cmd", $"/c \"{bat}\"") {
            CreateNoWindow = true, UseShellExecute = false
        });
    }
}

// UpdaterWindow.xaml.cs — tiny WPF window with a progress bar
public partial class UpdaterWindow : Window {
    public UpdaterWindow(string version) {
        InitializeComponent();
        Title = $"Updating to v{version}";
    }
    public void SetProgress(double p) =>
        Dispatcher.Invoke(() => Bar.Value = p * 100);
    public void SetStatus(string s) =>
        Dispatcher.Invoke(() => StatusLabel.Content = s);
}
/* UpdaterWindow.xaml:
<Window x:Class="YourApp.UpdaterWindow" Width="380" Height="140"
        WindowStartupLocation="CenterScreen" ResizeMode="NoResize">
  <StackPanel Margin="20">
    <Label x:Name="StatusLabel" Content="Downloading…"/>
    <ProgressBar x:Name="Bar" Height="18" Minimum="0" Maximum="100"/>
  </StackPanel>
</Window>
*/

Works the same in WinForms — replace Window with a small Form hosting a ProgressBar. For Avalonia, swap Dispatcher.Invoke for Dispatcher.UIThread.Post.

Admin API — build your own dashboard / Discord bot

Every endpoint below authenticates with your app's secret_key (find it in the dashboard → app settings). Treat it like a password — never ship it inside the app you give to end users. Use it from your own backend, a private script, a Discord bot, a CRM webhook, anything you control.

Base URL
https://vshll.lol/api/public/v1/admin
Auth header
Authorization: Bearer YOUR_APP_SECRET_KEY

Endpoints

MethodPathWhat it does
GET/licensesList up to 500 keys (newest first)
POST/licensesGenerate 1–100 keys. Body: count, expires_in_days, max_activations, note, ip_lock
GET/licenses/:keyFetch one key + its status
DELETE/licenses/:keyRevoke (permanently delete) a key
POST/licenses/:key/banBlacklist — validation immediately fails
POST/licenses/:key/unbanRe-enable a banned key
POST/licenses/:key/resetClear all HWID activations (user can re-activate on a new machine)
GET/logsLast 200 audit events for the app

Python client (use anywhere — scripts, Flask dashboards, automations)

python
# pip install requests
# Build your own dashboard, Discord bot, CRM hook — anything that should manage licenses.
# Use your APP SECRET KEY (from the dashboard), NEVER ship this in your end-user app.
import requests

API = "https://vshll.lol/api/public/v1/admin"
SECRET = "YOUR_APP_SECRET_KEY"
H = {"Authorization": f"Bearer {SECRET}", "Content-Type": "application/json"}

class Vshll:
    # ---- list / fetch ----
    def list_licenses(self):
        return requests.get(f"{API}/licenses", headers=H, timeout=15).json()

    def get_license(self, key):
        return requests.get(f"{API}/licenses/{key}", headers=H, timeout=15).json()

    # ---- create ----
    def create(self, count=1, expires_in_days=30, max_activations=1, note=None):
        body = {"count": count, "expires_in_days": expires_in_days,
                "max_activations": max_activations, "note": note}
        return requests.post(f"{API}/licenses", headers=H, json=body, timeout=15).json()

    # ---- moderation ----
    def ban(self, key):    return requests.post(f"{API}/licenses/{key}/ban",    headers=H, timeout=15).json()
    def unban(self, key):  return requests.post(f"{API}/licenses/{key}/unban",  headers=H, timeout=15).json()
    def reset(self, key):  return requests.post(f"{API}/licenses/{key}/reset",  headers=H, timeout=15).json()  # clear HWIDs
    def revoke(self, key): return requests.delete(f"{API}/licenses/{key}",      headers=H, timeout=15).json()  # delete forever

    # ---- audit trail ----
    def logs(self):
        return requests.get(f"{API}/logs", headers=H, timeout=15).json()

# --- example: sell a key, then later blacklist it ---
v = Vshll()
new = v.create(count=1, expires_in_days=30, note="customer #4821")
key = new["licenses"][0]["license_key"]
print("give this to the buyer:", key)

# later, if they chargeback:
v.ban(key)

Discord bot (slash commands for your staff)

python
# pip install discord.py requests
# Minimal Discord bot: /genkey  /ban  /unban  /reset  /lookup
import discord, requests
from discord import app_commands

API = "https://vshll.lol/api/public/v1/admin"
SECRET = "YOUR_APP_SECRET_KEY"
H = {"Authorization": f"Bearer {SECRET}", "Content-Type": "application/json"}
ADMIN_ROLE_ID = 123456789012345678  # restrict to your staff role

bot = discord.Client(intents=discord.Intents.default())
tree = app_commands.CommandTree(bot)

def is_admin(inter): return any(r.id == ADMIN_ROLE_ID for r in inter.user.roles)

@tree.command(description="Generate a new license key")
async def genkey(inter: discord.Interaction, days: int = 30):
    if not is_admin(inter): return await inter.response.send_message("Nope", ephemeral=True)
    r = requests.post(f"{API}/licenses", headers=H, timeout=15,
                      json={"count": 1, "expires_in_days": days, "max_activations": 1}).json()
    key = r["licenses"][0]["license_key"]
    await inter.response.send_message(f"`{key}` ({days}d)", ephemeral=True)

@tree.command(description="Ban a license key")
async def ban(inter: discord.Interaction, key: str):
    if not is_admin(inter): return await inter.response.send_message("Nope", ephemeral=True)
    requests.post(f"{API}/licenses/{key}/ban", headers=H, timeout=15)
    await inter.response.send_message(f"Banned `{key}`", ephemeral=True)

@tree.command(description="Clear all HWID activations on a key")
async def reset(inter: discord.Interaction, key: str):
    if not is_admin(inter): return await inter.response.send_message("Nope", ephemeral=True)
    requests.post(f"{API}/licenses/{key}/reset", headers=H, timeout=15)
    await inter.response.send_message(f"Reset `{key}`", ephemeral=True)

@bot.event
async def on_ready(): await tree.sync(); print("ready")

bot.run("YOUR_DISCORD_BOT_TOKEN")

C++ client (libcurl + nlohmann/json)

cpp
// C++17 + libcurl + nlohmann/json
// vcpkg install curl nlohmann-json
#include <curl/curl.h>
#include <nlohmann/json.hpp>
#include <string>
#include <iostream>
using json = nlohmann::json;

static size_t write_cb(void* p, size_t s, size_t n, void* u) {
    ((std::string*)u)->append((char*)p, s*n); return s*n;
}

class Vshll {
    std::string api = "https://vshll.lol/api/public/v1/admin";
    std::string secret;
    std::string req(const std::string& method, const std::string& path, const std::string& body = "") {
        CURL* c = curl_easy_init(); std::string out;
        curl_easy_setopt(c, CURLOPT_URL, (api + path).c_str());
        curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, method.c_str());
        curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, write_cb);
        curl_easy_setopt(c, CURLOPT_WRITEDATA, &out);
        curl_slist* h = nullptr;
        h = curl_slist_append(h, ("Authorization: Bearer " + secret).c_str());
        h = curl_slist_append(h, "Content-Type: application/json");
        curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
        if (!body.empty()) curl_easy_setopt(c, CURLOPT_POSTFIELDS, body.c_str());
        curl_easy_perform(c); curl_slist_free_all(h); curl_easy_cleanup(c);
        return out;
    }
public:
    Vshll(std::string s): secret(std::move(s)) {}
    json list()                           { return json::parse(req("GET",    "/licenses")); }
    json get(const std::string& k)        { return json::parse(req("GET",    "/licenses/" + k)); }
    json create(int days = 30, int slots = 1) {
        json b = {{"count", 1}, {"expires_in_days", days}, {"max_activations", slots}};
        return json::parse(req("POST", "/licenses", b.dump()));
    }
    json ban(const std::string& k)    { return json::parse(req("POST",   "/licenses/" + k + "/ban")); }
    json unban(const std::string& k)  { return json::parse(req("POST",   "/licenses/" + k + "/unban")); }
    json reset(const std::string& k)  { return json::parse(req("POST",   "/licenses/" + k + "/reset")); }
    json revoke(const std::string& k) { return json::parse(req("DELETE", "/licenses/" + k)); }
    json logs()                       { return json::parse(req("GET",    "/logs")); }
};

int main() {
    Vshll v("YOUR_APP_SECRET_KEY");
    auto r = v.create(30, 1);
    std::cout << r["licenses"][0]["license_key"].get<std::string>() << "\n";
    // v.ban("XXXX-XXXX-XXXX-XXXX-XXXX");
}

C# client (.NET 6+ HttpClient)

csharp
// .NET 6+ admin client. Build a WinForms/WPF/Avalonia dashboard,
// a console tool, or wire it into your store backend.
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

public class Vshll {
    const string API = "https://vshll.lol/api/public/v1/admin";
    readonly HttpClient http;

    public Vshll(string secretKey) {
        http = new HttpClient { BaseAddress = new Uri(API + "/") };
        http.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", secretKey);
    }

    public Task<JsonElement> ListLicenses()        => Get("licenses");
    public Task<JsonElement> GetLicense(string k)  => Get($"licenses/{k}");
    public Task<JsonElement> Logs()                => Get("logs");

    public Task<JsonElement> Create(int count = 1, int expiresInDays = 30,
                                    int maxActivations = 1, string? note = null) =>
        Post("licenses", new { count, expires_in_days = expiresInDays,
                               max_activations = maxActivations, note });

    public Task<JsonElement> Ban(string k)    => Post($"licenses/{k}/ban", null);
    public Task<JsonElement> Unban(string k)  => Post($"licenses/{k}/unban", null);
    public Task<JsonElement> Reset(string k)  => Post($"licenses/{k}/reset", null);
    public Task<JsonElement> Revoke(string k) => Delete($"licenses/{k}");

    async Task<JsonElement> Get(string p)   =>
        await (await http.GetAsync(p)).Content.ReadFromJsonAsync<JsonElement>();
    async Task<JsonElement> Post(string p, object? body) =>
        await (await http.PostAsJsonAsync(p, body ?? new {})).Content.ReadFromJsonAsync<JsonElement>();
    async Task<JsonElement> Delete(string p) =>
        await (await http.DeleteAsync(p)).Content.ReadFromJsonAsync<JsonElement>();
}

// --- usage ---
// var v = new Vshll("YOUR_APP_SECRET_KEY");
// var made = await v.Create(count: 1, expiresInDays: 30, note: "customer #4821");
// Console.WriteLine(made.GetProperty("licenses")[0].GetProperty("license_key"));
// await v.Ban("XXXX-XXXX-XXXX-XXXX-XXXX");
Security: the secret_key bypasses the end-user API and can mint/ban/delete keys at will. Keep it on a server or local machine you control. If it ever leaks, rotate it from the dashboard immediately.

Tips for production

  • Always verify the returned sha256 before replacing the binary — protects against tampering.
  • If mandatory: true, block app launch until the update finishes.
  • Run the update check on a background thread so the UI stays responsive.
  • The signed download URL expires in 5 minutes — download immediately, don't cache it.
  • For macOS/Linux replace the .bat swap with a shell script (mv + chmod +x + exec).
If a user's app "reaches the API but won't update":
  • Permissions: if the exe lives in Program Files, the swap needs admin rights. Install per-user (%LOCALAPPDATA%) or ship a UAC manifest.
  • Antivirus: Defender / 3rd-party AV often blocks freshly-downloaded exes silently. Whitelist your publisher or sign the binary.
  • File lock race: the updater above waits for the running PID to exit before move — older versions using a fixed ping delay would fail on slower disks.
  • Stale version.txt: if it contains the new version but the exe is still old (failed mid-update), delete it and the app will re-download on next launch.