Skip to main content

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

HeaderFormatPurpose
X-API-KeyUUID stringIdentifies the application. The server looks up the matching api_secret.
X-TimestampUnix seconds, 1 to 15 digitsAnti-replay clock.
X-Nonce16 to 128 chars, [A-Za-z0-9_-]Single-use identifier per request.
X-Signature64 lowercase hex charsHMAC-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:

  1. HTTP method, uppercased (GET, POST, and so on).
  2. Request path exactly as it appears in the URL (/api/client/login), no host, no query.
  3. Query string with no leading ?, or the empty string if none.
  4. X-Timestamp value, as sent.
  5. X-Nonce value, as sent.
  6. 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

RuleValue
Max clock skew, future60 seconds
Max clock skew, past300 seconds
Nonce cache TTL420 seconds
Max signed body1 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.

tip

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_key values 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 lineCause
sig: missing headersOne 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 shapeTimestamp is not 1 to 15 digits.
sig: stale tsTimestamp outside the skew window.
sig: invalid nonceNonce fails the regex.
sig: malformed signatureSignature is not 64 lowercase hex chars.
sig: hmac mismatchComputed HMAC does not equal the header value.
sig: replay detectedNonce already used within the TTL.

Repeated HMAC mismatches are counted as abuse and can trigger IP throttling. See Security Model for the details.

warning

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.