Skip to main content

API Overview

The Obsidian HTTP API is the wire protocol the SDKs speak, and every client-facing request and response is signed in both directions.

Base URL

Each application gets its own api_base_url, shown on the app's SDK Integration tab in the dashboard. Every client endpoint lives under a common prefix:

{api_base_url}/api/client/

There is no shared production domain baked into the SDKs. They read this URL from the config you paste in during setup, so different deployments never collide.

Wire protocol

Every client endpoint is POST over HTTPS with Content-Type: application/json; charset=utf-8. The body is always a JSON object; there are no form fields, no query parameters, no multipart uploads.

Successful responses come back as JSON with success: true and the endpoint's payload. Failures return an HTTP error status with a {"detail": "..."} body, and the SDK raises its native exception.

Body cap

The signed body is capped at 1 MiB. Anything larger is rejected with 413 Body too large before the signature is even checked.

Client endpoints

You get four core endpoints, all POST, all signature-required.

  • /api/client/init: hand the server your api_key and app version, receive a short-lived session token.
  • /api/client/register: register a new end user with a license key.
  • /api/client/login: authenticate an existing end user with username and password.
  • /api/client/license: validate a license key on its own, no username or password.

The session token returned by /init is a JWT bound to the application and valid for 2 hours. Every subsequent call passes it back as session in the request body, so /init runs once per launch, not once per request.

Request signing

Every request to /api/client/* carries three headers. Missing, malformed, stale, or reused values fail with 401 Request rejected.

HeaderFormatPurpose
X-TimestampUnix seconds, 1 to 15 digitsFreshness. Accepted skew is -300s to +60s from server time.
X-Nonce16 to 128 chars from [A-Za-z0-9_-]Replay defense. Single-use per app within the window.
X-Signature64 lowercase hex charsHMAC-SHA256 of the canonical string using your app's api_secret.

The canonical string is length-prefixed so no field can bleed into the next:

len(METHOD):METHOD\n
len(PATH):PATH\n
len(QUERY):QUERY\n
len(TS):TS\n
len(NONCE):NONCE\n
64:SHA256_HEX(BODY)

METHOD is upper-case, and PATH is the URL path without host. QUERY is the raw query string or empty.

BODY is the exact request bytes, hashed as 64 lowercase hex characters. All six lines join with \n.

Nonces are cached server-side for 420 seconds, so a repeated nonce on the same app returns 401 Request rejected. Reference implementations and a worked example live in Request Signing.

Response signing

Every response from /api/client/* is signed with the server's Ed25519 private key. The SDK verifies the signature against the pinned SPKI public key before your code ever sees the payload.

The response carries two headers: X-Response-Signature holds the base64-encoded Ed25519 signature, and X-Response-Kid names the key id (currently k1).

The signed message is:

METHOD\n
PATH\n
STATUS\n
REQ_NONCE\n
REQ_TS\n
RESPONSE_BODY

Binding the response to the request's nonce and timestamp shuts down replay, substitution, and downgrade attacks, even from a MITM holding a valid TLS certificate. The Security Model page walks through the full threat model.

HTTP status codes

The API uses standard HTTP semantics. On any non-2xx, the body is {"detail": "..."} and the SDK raises its native exception.

StatusMeaning
200Success. Payload is signed and safe to trust.
400Bad request. Invalid license, username taken, malformed input.
401Unauthorized. Bad credentials, expired session, invalid signature, replay.
403Forbidden. App disabled, license banned, HWID mismatch, blacklisted IP or HWID, heartbeat required.
404Not found. Application key does not resolve.
409Conflict. License activation is mid-flight, retry.
413Body too large. Signed body cap is 1 MiB.
426Upgrade required. Client version did not match and the app has force_update set.
429Rate limit exceeded, or the account, IP, or IP+account pair is temporarily locked out.
503Signature or session cache unavailable. Transient, retry with backoff.

Rate limits

The rate limiter runs per source IP, per endpoint, on a sliding window.

EndpointLimit
/api/client/init30 requests per 60 seconds
/api/client/register20 requests per 60 seconds
/api/client/login20 requests per 60 seconds
/api/client/license20 requests per 60 seconds

Exceeding a limit returns 429 Rate limit exceeded. Slow down. Repeated failed logins also trigger account, IP, and IP+account lockouts on top of the rate limiter, so tight retry loops will lock you out faster than they will succeed. Back off exponentially.

Idempotency

The endpoints are not idempotent in the REST sense, and there is no Idempotency-Key header. Two rules cover the real cases you will hit.

Nonces are single-use, so a retry needs a fresh nonce and a fresh signature. Reusing a nonce is indistinguishable from a replay attack and gets the same 401.

License activation is atomic on the server. /register and /license walk the license through unused to pending to used under a conditional update, and a retry inside that window returns 409 License activation in progress, retry. Wait a moment and retry with a new nonce.

Reuse sessions

Sessions returned by /init are valid for 2 hours. Call /init once at startup, cache the token, and reuse it for /register, /login, and /license.