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.
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 yourapi_keyand appversion, 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.
| Header | Format | Purpose |
|---|---|---|
X-Timestamp | Unix seconds, 1 to 15 digits | Freshness. Accepted skew is -300s to +60s from server time. |
X-Nonce | 16 to 128 chars from [A-Za-z0-9_-] | Replay defense. Single-use per app within the window. |
X-Signature | 64 lowercase hex chars | HMAC-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.
| Status | Meaning |
|---|---|
200 | Success. Payload is signed and safe to trust. |
400 | Bad request. Invalid license, username taken, malformed input. |
401 | Unauthorized. Bad credentials, expired session, invalid signature, replay. |
403 | Forbidden. App disabled, license banned, HWID mismatch, blacklisted IP or HWID, heartbeat required. |
404 | Not found. Application key does not resolve. |
409 | Conflict. License activation is mid-flight, retry. |
413 | Body too large. Signed body cap is 1 MiB. |
426 | Upgrade required. Client version did not match and the app has force_update set. |
429 | Rate limit exceeded, or the account, IP, or IP+account pair is temporarily locked out. |
503 | Signature or session cache unavailable. Transient, retry with backoff. |
Rate limits
The rate limiter runs per source IP, per endpoint, on a sliding window.
| Endpoint | Limit |
|---|---|
/api/client/init | 30 requests per 60 seconds |
/api/client/register | 20 requests per 60 seconds |
/api/client/login | 20 requests per 60 seconds |
/api/client/license | 20 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.
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.