For Developers · Non-Roblox SDK
Panda-SDK V5
Licensing for desktop software, in C# and C++ only. One managed DLL that speaks the PUSL-V4 protocol over plain HTTP: no native library, no libsodium, no WebSocket, no NuGet packages. v1.0.0, 2026-09-08.
Download
Panda-SDK V5 is a single .NET 8 assembly, Panda.Pusl.Client.dll. It has zero package references, so dropping it next to your executable is the whole install.
v1.0.0Target: net8.0Updated: 2026-09-08SHA-256: a2a46fb120dcda97…Reference it from your project:
<ItemGroup>
<Reference Include="Panda.Pusl.Client">
<HintPath>libPanda.Pusl.Client.dll</HintPath>
</Reference>
</ItemGroup>Sample usage
Create the client, hand the user the GetKey URL, validate what they type. On success the heartbeat starts on its own in the background.
using Panda.Pusl.Client;
var options = new PuslOptions
{
ServiceId = "your_service_identifier",
};
using var client = new PuslClient(options);
Console.WriteLine("Get a key: " + client.GetKeyUrl());
Console.Write("Enter key: ");
var key = Console.ReadLine() ?? "";
var result = await client.ValidateAsync(key);
if (!result.Success)
{
Console.WriteLine($"Auth failed [{result.ReasonCode}]: {result.Error}");
return result.Reason == PuslReason.InvalidKey ? 2 : 1;
}
Console.WriteLine($"Authenticated. Premium: {result.IsPremium}");
Console.WriteLine($"Expires: {client.GetExpiryFormatted()}");
Console.ReadLine();
await client.DisconnectAsync();
return 0;Do not kick on a bare false
ValidateAsync fails for a bad key, and it also fails for a rate limit or a dropped request. Read result.Reason before you act on it. Only INVALID_KEY means the key is bad.Wrap the call in the retry policy you should ship with:
// Only INVALID_KEY means the key is bad. Everything else is transient, so
// back off and retry instead of telling a paying user their key is wrong.
static async Task<PuslResult> Authenticate(PuslClient client, string key)
{
var result = await client.ValidateAsync(key);
for (var attempt = 1; attempt <= 3; attempt++)
{
if (result.Success || result.Reason == PuslReason.InvalidKey) break;
// A 404 is the slug being rejected, and it stays rejected.
if (result.HttpStatus == 404) break;
var wait = TimeSpan.FromSeconds(result.RetryAfterSeconds ?? attempt * 5);
await Task.Delay(wait);
result = await client.ValidateAsync(key);
}
return result;
}Watch the session
A session can end after a successful validate: the key expires, the heartbeat stops coming back, or the server closes it. Subscribe to SessionEnded and read IsConnected where the work happens.
// Fires when a live session drops on its own: "network", "protocol",
// "slug_rejected", "session_closed", or a reason the server sent.
client.SessionEnded += (_, reason) => _revoked = reason;
// IsConnected is false the moment the heartbeat stops coming back.
if (!client.IsConnected || _revoked is not null)
{
// Stop doing the paid work. Do not call Environment.Exit here.
}DisconnectAsync sends the goodbye and stops the heartbeat. Disposing the client without it leaves the session to time out server side.
Key expiry
client.ExpiresAt // "2026-08-09T22:15:00.000Z", null = lifetime
client.ExpiresAtUnix // 1786429200, null = lifetime
client.GetTimeLeft() // 604800 seconds, null = lifetime, floored at 0
client.IsExpired() // false for a lifetime key
client.GetExpiryFormatted()// "7d 0h 0m" | "Lifetime" | "Expired"
// null means LIFETIME, never "expired".
if (client.GetTimeLeft() is { } left && left < 86_400)
Warn($"Key expires in {client.GetExpiryFormatted()}");null means lifetime, not expired
GetTimeLeft and ExpiresAtUnix return null for a key with no expiry at all, which is why IsExpired() is false in that case. Time is measured against the server clock, adopted during the handshake, so a wrong system clock still counts down correctly.They also return null on a client that has not validated yet, so IsExpired() is false on a brand new client too. It is a countdown, not a gate. Pair it with IsConnected anywhere you use it to decide something.
Reason codes
| Code | Meaning | What to do |
|---|---|---|
| OK | Authenticated. | Continue. |
| INVALID_KEY | The key is wrong, used up, or expired. | Reject the user. This is the only fatal one. |
| RATE_LIMITED | Too many attempts from this address. | Wait RetryAfterSeconds, then retry. |
| NETWORK | The request failed or timed out. | Back off and retry. |
| PROTOCOL | The server replied with something unexpected. | Back off and retry. |
| IDENTITY | The server did not prove it was ours. | Do not authenticate. Check ServerPublicKeyHex. |
| CLOCK_SKEW | The device clock was too far off. | Retried once automatically, then retry yourself. |
| NO_SERVICE | ServiceId was not set. | Fix your configuration. |
| NO_KEY | An empty key was passed in. | Ask the user again. |
On a 429 the server says how long to wait and the client passes it through as result.RetryAfterSeconds. Add your own jitter on top.
Options
var options = new PuslOptions
{
ServiceId = "your_service_identifier",
// Must match the server. Rotate them together.
VerifyKey = "pusl_v4_vk_...",
SlugSecret = "pusl_v4_ss_...",
// Public half of the server signing key. A wrong value fails every
// handshake with IDENTITY.
ServerPublicKeyHex = "4dd84e1d...",
// Set this only if heartbeats come back 404.
SessionSlugServiceId = null,
// Leave null unless your product already has a device identity.
Hwid = null,
Executor = "MyApp/1.4.0",
Debug = false,
};ServiceIdstringrequiredYour service identifier from the dashboard.
VerifyKey / SlugSecretstringoptionalThe pair baked into your build. They must match the server, and they rotate together. Mixing one from each keyset gets past the handshake and then fails on the signature, which reads like a bad key and is not one.
ServerPublicKeyHexstringoptionalThe pinned server identity key. If it is not the public half of the key your region signs with, every handshake fails with IDENTITY.
SessionSlugServiceIdstring?optionalOnly needed if heartbeats come back 404. Put the service row id here. The SessionEnded reason for that case is slug_rejected.
Hwidstring?optionalOverrides the derived machine fingerprint. Whatever you pass has to be the same value the GetKey page saw, or the key resolves to a different device.
Debug / Loggerbool / ActionoptionalPrints the protocol trace. Turn it off in shipping builds: it names every step of the handshake.
Additional trick protections
The library protects the wire: rotating endpoints, an Ed25519 pinned server identity, a signed request body, and AES plus HMAC replies keyed per sequence number. None of that protects your executable. A .NET assembly decompiles cleanly, so the attack is never on the protocol, it is on the line where you branch. These are the tricks that matter.
1. Never gate on a single bool
This is the shape every cracker looks for:
// One branch, one place. In a decompiler this is a single brtrue to flip.
if (await client.ValidatePremiumAsync(key))
{
RunApp();
}One instruction flipped and the app runs. Carry the values the session produced into the code that needs them instead:
var result = await Authenticate(client, key);
if (!result.Success) return Exit(result);
// Carry values the app actually needs, not a flag. Patch the check out and
// the app is left with no session id and nothing to send to your backend.
var session = new Session(
Id: result.SessionId!,
Premium: result.IsPremium,
ExpiresUnix: result.ExpiresAtUnix,
Hwid: client.Hwid);
// Use them for real. Premium features read session.Premium, the status bar
// reads client.GetExpiryFormatted(), your API calls carry session.Id.
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("X-Panda-Session", session.Id);
var payload = await http.GetStringAsync("https://your-api.example/premium/data");Now removing the check does not unlock anything. It leaves the app with no session id, no expiry and nothing to send to your backend.
2. Put the value on your server
The only protection that cannot be patched out
result.SessionId with each request so your backend can refuse it. A crack of the client then buys an app with no data in it.3. Check often, where the work happens
One check at startup is one place to patch. Read live state at the point the feature runs, so there is no cached flag to flip:
// Bad: one gate at startup.
static bool _licensed;
// Better: the value is read where the work happens, every time.
sealed class Licence
{
private readonly PuslClient _client;
private readonly bool _premium;
public Licence(PuslClient client, PuslResult result)
=> (_client, _premium) = (client, result.IsPremium);
// No cached bool to flip, and it reads live session state.
public bool CanExport => _premium && _client.IsConnected && !_client.IsExpired();
}Do not call Environment.Exit the instant a check fails. An immediate exit points straight at the check that triggered it. Degrade quietly instead.
4. Treat the baked secrets as obscurity
VerifyKey and SlugSecret ship inside the assembly, exactly as they do in the compiled native SDK. They raise the effort of talking to the protocol, they are not authentication. Real authenticity comes from the signed body, the Ed25519 identity on the server hello, and the signed session cookie.
Rotation locks out shipped builds
PUSL_*_PREV grace window breaks every build already in the hands of users until they update.5. Keep the identity pin on
VerifyServerIdentity defaults to true and should stay that way. With it off, anything that can redirect your host, a hosts file entry or a local proxy, answers the handshake and your app believes it. The pin also checks CanonicalHost and rejects a signature more than 60 seconds old, so a captured hello cannot be replayed later.
The pin only grades a signed hello
signature, hostname or ts at all is accepted, flag on or off, because a dev server with no signing seed has to be able to answer. The pin is a check on what a real node sends, not a guarantee that something answered as one. Treat the handshake as the start of trust and let your own backend be the thing that decides.6. Understand the HWID before you override it
The default fingerprint is a SHA-256 over the machine name, platform, processor count, the Linux machine-id when present, and the sorted physical MAC addresses. It moves when the machine changes:
- Renaming the machine changes it.
- Adding or removing a network adapter changes it, and a VPN adapter counts.
Set PuslOptions.Hwid only if your product already has a device identity of its own. Never hardcode one: every copy then shares a device, and one key covers all of them.
7. Harden the build last
# Ship a single file, trimmed, with no debug symbols next to it.
dotnet publish -c Release -r win-x64 -p:PublishSingleFile=true -p:SelfContained=true -p:DebugType=none
# Then obfuscate the output. Do this on top of server-side gating, never
# instead of it.Publish single file, strip the symbols, then obfuscate. Shipping a .pdb next to the assembly hands a reader your original names and line numbers for free. All of this raises effort, none of it replaces the server holding the value.
What not to do
- Do not kick on a bare false. A rate limit is not a bad key.
- Do not treat a null time left as expired. Null means lifetime.
- Do not turn off
VerifyServerIdentityin a shipping build. - Do not leave
Debugon. It traces the whole handshake. - Do not hardcode a HWID, and do not send one the GetKey page never saw.
Coming from the native DLL
Older builds call the native PandaLib_PUSL.dll through P/Invoke. PandaSdk in this library is a static facade with the same shape, so existing call sites compile against the managed assembly with no rewrite:
PandaSdk.Validate(service, key)andValidate_PremiumPandaSdk.Get_Key(service),IsValid(),Shutdown()PandaSdk.PandaGetLastError(), which returnsREASON: message
It is a migration path, not the target. It blocks, it holds process-wide state, and its boolean return is the exact shape trick protection 1 tells you to avoid. New code should use PuslClient directly.
The C++ header for the native DLL is unchanged, and the old package is still at /downloads/PandaSDK_V5.zip:
// panda_v5.h
#pragma once
#ifdef PANDA_V5_EXPORTS
#define PANDA_API extern "C" __declspec(dllexport)
#else
#define PANDA_API extern "C" __declspec(dllimport)
#endif
// Connect to server, validate key, start heartbeat.
PANDA_API bool Validate(const char* service, const char* key);
// Returns the GetKey URL with auto-HWID appended.
PANDA_API const char* Get_Key(const char* service);
// Check if the session is still alive (heartbeat ok, not expired).
// Fails if the internal thread detects debugging or heartbeat drops.
PANDA_API bool IsValid();
// Graceful disconnect.
PANDA_API void Shutdown();
// Get the last error message string.
PANDA_API const char* PandaGetLastError();