Skip to main content

Heartbeat

The heartbeat protocol keeps an authenticated session alive on the server and kills any client that stops behaving like an untampered process. Turn it on per application with Require Heartbeat, and /login, /register, and /license will refuse to run unless a live heartbeat session backs the same nonce.

Three endpoints live under /api/client/heartbeat: /start opens a heartbeat session and hands you the first rotating token, the base path accepts a beat and returns the next token, and /stop closes the session cleanly. All three sit behind request signing and inherit the standard blacklist checks and per-IP rate limits.

Lifecycle

The order matters. A heartbeat session can only be opened on a session nonce that has already been activated by an auth call.

  1. Call /api/client/init to get a session.
  2. Authenticate with /api/client/register, /api/client/login, or /api/client/license. The server marks the nonce as activated for the next 24 hours.
  3. Call /api/client/heartbeat/start with the same session and a stable system_id. The server issues the first token and returns the negotiated beat_interval in seconds.
  4. Every beat_interval seconds, call /api/client/heartbeat with the current token, a monotonically increasing beat_index, and the process code_hash. The response carries the next token.
  5. On clean shutdown, call /api/client/heartbeat/stop with the latest token.

If you jump straight to step 3 without an activated nonce, the server responds with HTTP 403 and detail Session not activated. Call /login, /register or /license first.

Rotating token flow

Each successful beat consumes the current token and returns a fresh one. Every token is 34 characters: the literal prefix TT followed by 32 hex characters (16 random bytes from secrets.token_hex).

The client keeps the previous response's new_token, sends it back as token on the next beat, then overwrites its stored copy with the new value. Comparisons on the server use hmac.compare_digest, so a wrong or stale token gets the same generic rejection with no timing leak.

warning

There is no way to recover a lost token. If a beat response drops on the wire, the session is unrecoverable, and the client has to re-authenticate and call /heartbeat/start again.

beat_index rules

beat_index is the client's monotonic counter within one heartbeat session.

  • Start it at 1 for the first beat after /start.
  • Increase it strictly on every subsequent beat.
  • Keep it inside [1, 2^31]; the schema rejects anything outside.
  • Any value less than or equal to the server's last_beat_index is refused as a replay.

The server only advances last_beat_index when the beat is otherwise valid, so a rejected beat cannot be retried with the same index to slip through.

code_hash, code_size, and the debugger flag

code_hash and code_size feed the injection-detection channel. Compute them over your executable image (or the protected module region) and send them on every beat. code_hash is normalized to lowercase server-side and must be 8 to 128 characters; code_size is optional, and when present it is compared against the baseline.

Two baselining modes exist.

Server-pinned. If the application document has expected_binary_hash set, every beat must match. A mismatched hash, or a code_size that disagrees with the pinned baseline, kills the session and deletes it from Redis.

First-beat baseline. With no pinned hash, the first beat's code_hash becomes the baseline for that session, and every later beat must match. A first beat with no code_hash at all is killed under missing baseline code_hash on beat 1.

debugger_detected is a boolean the client raises when its own anti-debug checks trip. The server honors it only when the application has injection_detection enabled (on by default). A single true on an otherwise valid beat is enough to kill the session.

Timing

The negotiated beat_interval is clamped to [5, 300] seconds, defaulting to 30 when the application has no override. Beat once per interval; do not overlap beats, and do not skip them.

Liveness. A session counts as alive when the elapsed time since the last beat is at most beat_interval * 3. /login and /license check this via is_session_alive when require_heartbeat is on. A fresh session with zero beats is treated as alive.

Drift kill. On every beat, the server measures elapsed time since the previous beat. When it exceeds beat_interval * 2.5, a tamper warning is recorded, and three warnings kill the session. A clean-timing beat decays the warning counter by one, so occasional jitter is forgiven.

Redis TTL. The session record's TTL is max(60, beat_interval * 6) and resets on every accepted beat. A client that stops beating entirely is evicted after that window.

Kill conditions

Any of these events delete the heartbeat session immediately and force a full re-authentication. Killed sessions all respond with HTTP 403 and detail rejected; there is no distinguishing string for the client to key off, by design.

ConditionLog line
system_id mismatch versus the value at /startheartbeat: system_id mismatch
Missing code_hash on the first beat (unpinned)heartbeat: missing baseline code_hash on beat 1
code_hash mismatch versus first-beat baselineheartbeat: captured integrity violation
code_size mismatch versus first-beat baselineheartbeat: baseline_size mismatch
code_hash mismatch versus pinned baselineheartbeat: pinned integrity violation
code_size mismatch versus pinned baselineheartbeat: pinned baseline_size mismatch
Three drift warnings accumulatedtiming anomaly counter
debugger_detected: true with injection guard onheartbeat: debugger flagged on authenticated beat

POST /api/client/heartbeat/start

Request body:

{
"session": "<session from /init>",
"system_id": "<stable device fingerprint, 8-128 chars>"
}

Success (200):

{
"success": true,
"token": "TT<32 hex>",
"beat_interval": 30
}

Errors:

StatusDetailCause
403Session not activated. Call /login, /register or /license first.Session nonce was never activated by an auth call
403Application disabledApp is disabled
404Application not foundSession nonce refers to an unknown app
409heartbeat session already exists for this nonce/start called twice on the same nonce

POST /api/client/heartbeat

Request body:

{
"session": "<session from /init>",
"token": "<token from previous response>",
"system_id": "<same value passed to /start>",
"beat_index": 1,
"code_hash": "<lowercase hex, 8-128 chars>",
"code_size": 12345,
"debugger_detected": false
}

code_hash, code_size, and debugger_detected are optional in the schema, but code_hash is effectively required: the very first beat without it is killed under the unpinned rule.

Success (200):

{
"success": true,
"new_token": "TT<32 hex>",
"beat_count": 1
}

Errors:

StatusDetailMeaning
403rejectedAny of the kill conditions above
403Application disabledApp was disabled between beats
404Application not foundSession nonce refers to an unknown app

POST /api/client/heartbeat/stop

Request body:

{
"session": "<session from /init>",
"token": "<latest token>"
}

Success (200):

{"success": true}

Errors:

StatusDetailMeaning
401Invalid heartbeat tokenToken does not match the current session token, or the session is already gone

C++ reference loop

The C++ SDK exposes three functions that mirror the endpoints one-to-one. Every response carries an ObsidianCallProof that you must run through obsidian_verify_proof before trusting the returned token.

OBSIDIAN_API int obsidian_heartbeat_start_v2(
const ObsidianConfig* cfg,
const char* session,
const char* system_id,
char* out_token, size_t token_len,
int* out_interval,
char* out_err, size_t err_len,
ObsidianCallProof* out_proof);

OBSIDIAN_API int obsidian_heartbeat_beat_v2(
const ObsidianConfig* cfg,
const char* session,
const char* current_token,
const char* system_id,
int beat_index,
const char* code_hash_or_null,
int64_t code_size_or_zero,
char* out_new_token, size_t token_len,
char* out_err, size_t err_len,
ObsidianCallProof* out_proof);

OBSIDIAN_API int obsidian_heartbeat_stop_v2(
const ObsidianConfig* cfg,
const char* session,
const char* token,
char* out_err, size_t err_len,
ObsidianCallProof* out_proof);

A minimal loop after a successful login looks like this:

char token[128] = {0};
int interval = 0;
char err[256] = {0};
ObsidianCallProof proof{};

if (obsidian_heartbeat_start_v2(&cfg, session, system_id,
token, sizeof(token),
&interval,
err, sizeof(err),
&proof) != 0) {
return 1;
}
if (!obsidian_verify_proof(&proof, &cfg, token, strlen(token))) {
return 1;
}

int beat_index = 1;
char new_token[128] = {0};

while (running) {
sleep_seconds(interval);

char code_hash[65] = {0};
int64_t code_size = 0;
compute_module_sha256(code_hash, sizeof(code_hash), &code_size);

int rc = obsidian_heartbeat_beat_v2(&cfg, session, token, system_id,
beat_index,
code_hash, code_size,
new_token, sizeof(new_token),
err, sizeof(err),
&proof);
if (rc != 0) {
break;
}
if (!obsidian_verify_proof(&proof, &cfg, new_token, strlen(new_token))) {
break;
}
strncpy(token, new_token, sizeof(token) - 1);
beat_index++;
}

obsidian_heartbeat_stop_v2(&cfg, session, token, err, sizeof(err), &proof);
danger

Skip obsidian_verify_proof and the returned new_token could be from anyone sitting between you and Obsidian. A rejected proof means the response is not authentic, and the beat must be treated as failed.