Request Signing
Every client call to /api/client/* and /api/heartbeat/* is authenticated with an HMAC-SHA256 signature over a canonical request string.
For apps with require_signature enabled (the default), the server rejects any request whose signature is missing, malformed, stale, or replayed. Apps that flip signing off skip the check and accept unsigned traffic. The SDKs sign for you; this page documents the wire format so you can debug failures, build your own client, or verify traffic from a proxy.
Required headers
| Header | Format | Purpose |
|---|---|---|
X-API-Key | UUID string | Identifies the application. The server looks up the matching api_secret. |
X-Timestamp | Unix seconds, 1 to 15 digits | Anti-replay clock. |
X-Nonce | 16 to 128 chars, [A-Za-z0-9_-] | Single-use identifier per request. |
X-Signature | 64 lowercase hex chars | HMAC-SHA256 over the canonical string, keyed with api_secret. |
Content-Type: application/json is expected for POSTs. The body is signed as raw bytes, so you must sign exactly what you send on the wire.
Canonical string
The server rebuilds the canonical string from the request and recomputes the HMAC. Any mismatch on any field fails verification.
The six signed parts, in order:
- HTTP method, uppercased (
GET,POST, and so on). - Request path exactly as it appears in the URL (
/api/client/login), no host, no query. - Query string with no leading
?, or the empty string if none. X-Timestampvalue, as sent.X-Noncevalue, as sent.- SHA-256 hex digest of the raw request body. For empty bodies, hash the empty byte string (
e3b0c44...b855).
Each part is length-prefixed with its byte length, then a colon, then the value. The six length-prefixed parts are joined with a single newline (\n), and there is no trailing newline.
{len(method)}:{METHOD}\n
{len(path)}:{path}\n
{len(query)}:{query}\n
{len(ts)}:{ts}\n
{len(nonce)}:{nonce}\n
64:{sha256_hex(body)}
The length prefix prevents field-boundary attacks, where a suffix of one field slides into the next. Concatenating without prefixes produces a different signature and the server will reject the request.
The signature itself is:
X-Signature = hex(HMAC_SHA256(api_secret_utf8, canonical_utf8))
Hex output is lowercase. The server's regex only accepts [0-9a-f]{64}, so uppercase or truncated hex is rejected before the HMAC compare even runs.
Timing rules
| Rule | Value |
|---|---|
| Max clock skew, future | 60 seconds |
| Max clock skew, past | 300 seconds |
| Nonce cache TTL | 420 seconds |
| Max signed body | 1 MiB |
A timestamp more than 60 seconds ahead of server time or more than 300 seconds behind is rejected as stale. Sync client clocks with NTP before shipping, or you will see intermittent Request rejected errors on machines with skewed clocks.
The nonce TTL (420 seconds) is past skew plus future skew plus a minute of grace. It is longer than the stale window on purpose, so a nonce cannot escape replay detection by riding the edge of the timestamp window.
Nonce rules
- Generate a fresh nonce for every request.
secrets.token_urlsafe(24)or a UUIDv4 with dashes stripped both fit. - The server stores each accepted nonce for 420 seconds and rejects any replay within that window.
- Nonces are scoped per application, so the same nonce can be reused across two different
api_keyvalues but never twice for the same app. - The nonce must match
[A-Za-z0-9_-]{16,128}. Base64 padding (=) is not allowed. Use URL-safe base64 or hex.
Failure modes
All signature failures return HTTP 401 with body {"detail": "Request rejected"}. The server intentionally does not tell you which check failed; log-side, the reasons are:
| Log line | Cause |
|---|---|
sig: missing headers | One of X-Signature, X-Timestamp, or X-Nonce is absent. A missing X-API-Key fails earlier with HTTP 404 Application not found. |
sig: invalid ts shape | Timestamp is not 1 to 15 digits. |
sig: stale ts | Timestamp outside the skew window. |
sig: invalid nonce | Nonce fails the regex. |
sig: malformed signature | Signature is not 64 lowercase hex chars. |
sig: hmac mismatch | Computed HMAC does not equal the header value. |
sig: replay detected | Nonce already used within the TTL. |
Repeated HMAC mismatches are counted as abuse and can trigger IP throttling. See Security Model for the details.
A body larger than 1 MiB returns HTTP 413 Body too large instead of 401. The size check runs after the header-shape checks but before the HMAC compare, so an oversized body with a bad signature still surfaces as 413.
Python reference
The Python SDK ships this signer in obsidian.py. Reproduced here so you can port it to any runtime.
import hashlib
import hmac
import secrets
import time
def sign_request(api_secret: str, method: str, path: str, query: str, body: bytes):
ts = str(int(time.time()))
nonce = secrets.token_urlsafe(24)
body_hash = hashlib.sha256(body).hexdigest()
parts = [method.upper(), path, query, ts, nonce, body_hash]
canonical = "\n".join(f"{len(p)}:{p}" for p in parts)
signature = hmac.new(
api_secret.encode("utf-8"),
canonical.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return {
"X-API-Key": "your-app-api-key",
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Signature": signature,
}
Call it with the exact bytes you will send on the wire. If you serialize a dict with json.dumps, sign the encoded string, not the dict.
C# reference
The C# SDK builds the same canonical form in Obsidian.cs. Standalone version for a custom client:
using System;
using System.Security.Cryptography;
using System.Text;
public static class ObsidianSigner
{
public static (string Ts, string Nonce, string Signature) Sign(
string ApiSecret, string Method, string Path, string Query, byte[] Body)
{
var Ts = ((long)(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds).ToString();
var NonceBytes = new byte[18];
RandomNumberGenerator.Fill(NonceBytes);
var Nonce = Convert.ToBase64String(NonceBytes)
.Replace('+', '-').Replace('/', '_').TrimEnd('=');
string BodyHash;
using (var Sha = SHA256.Create())
BodyHash = Convert.ToHexString(Sha.ComputeHash(Body)).ToLowerInvariant();
var Parts = new[] { Method.ToUpperInvariant(), Path, Query, Ts, Nonce, BodyHash };
var Sb = new StringBuilder();
for (int I = 0; I < Parts.Length; I++)
{
var P = Parts[I];
Sb.Append(Encoding.UTF8.GetByteCount(P)).Append(':').Append(P);
if (I < Parts.Length - 1) Sb.Append('\n');
}
using var Hmac = new HMACSHA256(Encoding.UTF8.GetBytes(ApiSecret));
var Sig = Convert.ToHexString(
Hmac.ComputeHash(Encoding.UTF8.GetBytes(Sb.ToString()))
).ToLowerInvariant();
return (Ts, Nonce, Sig);
}
}
The length prefix uses UTF-8 byte length, not .Length. For ASCII paths and hex digests the two match, but a non-ASCII path would drift and every signature would fail.
curl test
Paste this into a bash shell to sign and send a live /api/client/init request. Replace APP_KEY and APP_SECRET with values from the SDK Integration tab.
APP_KEY="your-app-api-key"
APP_SECRET="your-app-api-secret"
METHOD="POST"
PATH_="/api/client/init"
QUERY=""
BODY='{"version":"1.0.0"}'
TS=$(date +%s)
NONCE=$(python -c "import secrets; print(secrets.token_urlsafe(24))")
BODY_HASH=$(printf '%s' "$BODY" | sha256sum | cut -d' ' -f1)
CANONICAL=$(printf '%s:%s\n%s:%s\n%s:%s\n%s:%s\n%s:%s\n%s:%s' \
${#METHOD} "$METHOD" \
${#PATH_} "$PATH_" \
${#QUERY} "$QUERY" \
${#TS} "$TS" \
${#NONCE} "$NONCE" \
64 "$BODY_HASH")
SIG=$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$APP_SECRET" | awk '{print $2}')
curl -sS -X POST "https://api.obsidian.example${PATH_}" \
-H "Content-Type: application/json" \
-H "X-API-Key: $APP_KEY" \
-H "X-Timestamp: $TS" \
-H "X-Nonce: $NONCE" \
-H "X-Signature: $SIG" \
--data-binary "$BODY"
If the response is {"detail":"Request rejected"}, walk the failure-mode table above. The most common cause is a body-hash mismatch: some HTTP libraries add a trailing newline or reformat JSON before sending, which changes the bytes the server hashes.
Response signing
Every signed response carries X-Server-Signature (Ed25519, base64) and X-Server-Kid. That is a separate protocol covered under Security Model, and it is what the SDKs pin against the server public key.