Skip to main content

Python SDK

Reference for the Obsidian Python SDK.

The SDK ships as a prebuilt CPython 3.12 extension named obsidian_sdk.cp312-win_amd64.pyd, generated per application from the dashboard. The .pyd bakes in the base URL, the pinned server certificate SPKI hash, and the pinned Ed25519 signing key with its key id. Your api_key and api_secret are not compiled in; you pass them at construction time, and the bundled example.py just prefills them as string literals for convenience.

Requirements

RequirementValue
OSWindows (x64)
PythonCPython 3.12, 64-bit only
Runtime depsrequests, cryptography
Optionalpynacl (used automatically if installed, otherwise falls back to cryptography)
Screenshot capturemss + Pillow (optional; falls back to a GDI stub)

A .pyd built for 3.12 will not import into 3.11, 3.13, or any other interpreter. PyPy is not supported. Linux and macOS are not supported either, since the module leans on winreg, wmic, and DPI-aware GDI calls.

Install the runtime deps:

py -3.12 -m pip install requests cryptography

Install

The ZIP downloaded from Dashboard, App, SDK Integration, Python contains three files:

obsidian_sdk/
obsidian_sdk.cp312-win_amd64.pyd
example.py
README.md

Drop the .pyd anywhere on sys.path (next to your entry script is fine) and import it as a normal module:

from obsidian_sdk import Obsidian, ObsidianError

Class Obsidian

Constructor:

Obsidian(
api_key: str,
api_secret: str,
version: str,
capture: bool = True,
server_pubkey: str | None = None,
server_kid: str | None = None,
)
ParameterTypeRequiredDefaultPurpose
api_keystryesApplication API key from the dashboard.
api_secretstryesHMAC secret used to sign every request. Not compiled into the .pyd; it lives only in process memory after construction. Never log it or ship it in plaintext in your own source or config.
versionstryesCompared against the version set on the dashboard. A mismatch raises HTTP 426 only when the app has force_update enabled; otherwise init() succeeds and the server just writes a client.version_mismatch audit entry.
captureboolnoTrueIf True, attaches a base64 PNG screenshot to register, login, and license calls.
server_pubkeystrnoNoneBase64 Ed25519 public key override. The pinned key is compiled in; only pass this to override during rotation.
server_kidstrnoNoneKey id override. Defaults to the compiled-in SERVER_KID.

Missing or non-string api_key, api_secret, or version raise ObsidianError before any network call touches the server.

Instance attributes

AttributeTypeWhen populatedNotes
api_keystrConstructorAs passed in.
versionstrConstructorAs passed in.
hwidstrConstructorSHA-256 of MachineGuid | Win32_ComputerSystemProduct.UUID | ProcessorId | DiskDrive.SerialNumber, first 32 hex chars. See HWID Lock.
sessionstr | NoneAfter init()Opaque signed session token. Required by every non-init call. Starts as None.
userdict | NoneAfter register() / login(){"username", "level", "expires_at"}.
captureboolConstructorMutable at runtime; set to False immediately before a call to suppress that call's screenshot.
server_kidstrConstructorEffective key id used to validate response signatures.
info

After construction the raw secret string is stored as bytes in a private attribute, _api_secret_bytes, and used only for HMAC signing. You should still treat the value you pass in as sensitive: keep it out of source control, crash reports, and telemetry.

Methods

init() -> dict

Opens a session against /api/client/init, sets self.session on success, and returns the app dict:

sdk = Obsidian(api_key="...", api_secret="...", version="1.0.0")
app = sdk.init()

app looks like {"name": "MyApp", "version": "1.0.0", "hwid_lock": True}.

register, login, and license all call init() automatically when self.session is None, so a bare sdk.login(...) on a fresh instance works.

register(username, password, license_key) -> dict | None

Consumes an unused license key and creates an end-user account. The call sends hwid and, when capture is on, a base64 PNG screenshot alongside the credentials.

user = sdk.register("player1", "s3cret", "XXXX-XXXX-XXXX-XXXX")

After a successful registration the SDK immediately calls login() with the same credentials, so last_login is populated on the dashboard and self.user reflects the login response. If that follow-up login raises, the exception is swallowed and self.user still holds the register response.

login(username, password) -> dict | None

Authenticates an existing end-user and sets self.user on success:

user = sdk.login("player1", "s3cret")

user is a dict shaped like {"username": "player1", "level": 1, "expires_at": "2026-12-31T00:00:00+00:00"}.

license(license_key) -> dict

License-only login for apps that skip usernames entirely. Returns the raw server payload:

info = sdk.license("XXXX-XXXX-XXXX-XXXX")

info looks like {"success": True, "message": "License valid", "level": 1, "expires_at": "2026-12-31T00:00:00+00:00"}.

Exception ObsidianError

Every failure raises ObsidianError. That covers client-side validation, TLS pin mismatch, missing or bad response signature, and any non-2xx server response. For server errors the message is the FastAPI detail field verbatim.

try:
sdk.login("player1", "wrong-password")
except ObsidianError as e:
print(str(e))

Client-side error strings

MessageCause
api_key is requiredConstructor got empty or non-str api_key.
api_secret is requiredConstructor got empty or non-str api_secret.
version is requiredConstructor got empty or non-str version.
server certificate pin mismatch (MITM or reissued cert)The server presented a cert whose SPKI SHA-256 does not match the pin baked into the .pyd.
unsigned server response (refusing to trust)Response was missing the X-Response-Signature header.
unknown server signing key '<kid>' (SDK expected '<expected>')X-Response-Kid header did not match the compiled-in key id.
invalid server signature (response forged or tampered): <ExceptionType>Ed25519 verification of the response failed.
unsupported path: '<path>'Internal guard; the path contained characters outside [A-Za-z0-9._~/-].

Server-side error strings (surfaced as-is)

HTTPMessageSource
400Invalid license keyregister, license
400License key already usedregister
400Username already takenregister
401Invalid username or passwordlogin (also returned for banned users, expired accounts, and HWID mismatch; do not leak which)
403Application disabledany call, app kill-switched
403License validation is temporarily paused for this application.register, license
403Access is temporarily paused for this application.login
403Heartbeat session required. Call /heartbeat/start first.login, license when the app requires heartbeats
403License key is bannedregister, license
403License not validlicense
403License expiredlicense
403HWID mismatch. Key locked to another device.license
409License activation in progress, retry.license (another activation is holding a pending lock)
409License state changed mid-activation, please retryregister
426Update-required dict {message, latest_version, update_url, force_update}init when the app has force_update on and versions mismatch
429Too many failed attempts, try again later.register / login lockout
tip

On login failure, the server deliberately returns the same 401 message for a bad password, a banned user, an expired account, or a HWID mismatch. Do not try to reverse-engineer which one you hit; treat 401 as "credentials rejected" in your UI.

Complete example

from obsidian_sdk import Obsidian, ObsidianError

sdk = Obsidian(
api_key="ak_live_...",
api_secret="as_live_...",
version="1.0.0",
capture=True,
)

try:
app = sdk.init()
print(f"connected to {app['name']} v{app['version']}")

user = sdk.login("player1", "s3cret")
if user is None:
raise ObsidianError("login returned no user")

print(f"welcome {user['username']}, tier {user['level']}, expires {user['expires_at']}")

except ObsidianError as e:
print(f"auth failed: {e}")
raise SystemExit(1)

PyInstaller bundling

The SDK is a compiled extension, not a pure-Python module, so PyInstaller's default scan will not pick it up unless it is imported at module scope. Bundle it explicitly as a binary:

pyinstaller ^
--onefile ^
--add-binary "obsidian_sdk.cp312-win_amd64.pyd;." ^
--hidden-import requests ^
--hidden-import cryptography ^
your_app.py

The frozen exe must run on CPython 3.12 x64. PyInstaller embeds whichever interpreter it was invoked with, so build with py -3.12 -m PyInstaller ....

warning

Do not rename the .pyd. The filename obsidian_sdk.cp312-win_amd64.pyd is what CPython's import machinery looks for; changing it breaks the import.

With --onefile, PyInstaller extracts to a temp directory at launch. The SDK does not read files from disk after import, so this works without extra hooks. If cryptography fails to import inside the frozen exe, add --collect-submodules cryptography.

Rotating the SDK

The .pyd pins the base URL, the server certificate SPKI hash, and the Ed25519 signing key with its key id. API key and secret are constructor arguments, so rotating them only means updating whatever config you pass to Obsidian(...).

If you rotate the pinned cert or the signing key on the dashboard, the old .pyd will start failing. The typical symptom is either server certificate pin mismatch or unknown server signing key; re-download from Dashboard, App, SDK Integration, Python and ship the new file.