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
| Requirement | Value |
|---|---|
| OS | Windows (x64) |
| Python | CPython 3.12, 64-bit only |
| Runtime deps | requests, cryptography |
| Optional | pynacl (used automatically if installed, otherwise falls back to cryptography) |
| Screenshot capture | mss + 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,
)
| Parameter | Type | Required | Default | Purpose |
|---|---|---|---|---|
api_key | str | yes | Application API key from the dashboard. | |
api_secret | str | yes | HMAC 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. | |
version | str | yes | Compared 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. | |
capture | bool | no | True | If True, attaches a base64 PNG screenshot to register, login, and license calls. |
server_pubkey | str | no | None | Base64 Ed25519 public key override. The pinned key is compiled in; only pass this to override during rotation. |
server_kid | str | no | None | Key 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
| Attribute | Type | When populated | Notes |
|---|---|---|---|
api_key | str | Constructor | As passed in. |
version | str | Constructor | As passed in. |
hwid | str | Constructor | SHA-256 of MachineGuid | Win32_ComputerSystemProduct.UUID | ProcessorId | DiskDrive.SerialNumber, first 32 hex chars. See HWID Lock. |
session | str | None | After init() | Opaque signed session token. Required by every non-init call. Starts as None. |
user | dict | None | After register() / login() | {"username", "level", "expires_at"}. |
capture | bool | Constructor | Mutable at runtime; set to False immediately before a call to suppress that call's screenshot. |
server_kid | str | Constructor | Effective key id used to validate response signatures. |
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
| Message | Cause |
|---|---|
api_key is required | Constructor got empty or non-str api_key. |
api_secret is required | Constructor got empty or non-str api_secret. |
version is required | Constructor 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)
| HTTP | Message | Source |
|---|---|---|
| 400 | Invalid license key | register, license |
| 400 | License key already used | register |
| 400 | Username already taken | register |
| 401 | Invalid username or password | login (also returned for banned users, expired accounts, and HWID mismatch; do not leak which) |
| 403 | Application disabled | any call, app kill-switched |
| 403 | License validation is temporarily paused for this application. | register, license |
| 403 | Access is temporarily paused for this application. | login |
| 403 | Heartbeat session required. Call /heartbeat/start first. | login, license when the app requires heartbeats |
| 403 | License key is banned | register, license |
| 403 | License not valid | license |
| 403 | License expired | license |
| 403 | HWID mismatch. Key locked to another device. | license |
| 409 | License activation in progress, retry. | license (another activation is holding a pending lock) |
| 409 | License state changed mid-activation, please retry | register |
| 426 | Update-required dict {message, latest_version, update_url, force_update} | init when the app has force_update on and versions mismatch |
| 429 | Too many failed attempts, try again later. | register / login lockout |
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 ....
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.