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.
- Call
/api/client/initto get asession. - Authenticate with
/api/client/register,/api/client/login, or/api/client/license. The server marks the nonce as activated for the next 24 hours. - Call
/api/client/heartbeat/startwith the samesessionand a stablesystem_id. The server issues the first token and returns the negotiatedbeat_intervalin seconds. - Every
beat_intervalseconds, call/api/client/heartbeatwith the current token, a monotonically increasingbeat_index, and the processcode_hash. The response carries the next token. - On clean shutdown, call
/api/client/heartbeat/stopwith 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.
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
1for 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_indexis 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.
| Condition | Log line |
|---|---|
system_id mismatch versus the value at /start | heartbeat: 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 baseline | heartbeat: captured integrity violation |
code_size mismatch versus first-beat baseline | heartbeat: baseline_size mismatch |
code_hash mismatch versus pinned baseline | heartbeat: pinned integrity violation |
code_size mismatch versus pinned baseline | heartbeat: pinned baseline_size mismatch |
| Three drift warnings accumulated | timing anomaly counter |
debugger_detected: true with injection guard on | heartbeat: 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:
| Status | Detail | Cause |
|---|---|---|
| 403 | Session not activated. Call /login, /register or /license first. | Session nonce was never activated by an auth call |
| 403 | Application disabled | App is disabled |
| 404 | Application not found | Session nonce refers to an unknown app |
| 409 | heartbeat 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:
| Status | Detail | Meaning |
|---|---|---|
| 403 | rejected | Any of the kill conditions above |
| 403 | Application disabled | App was disabled between beats |
| 404 | Application not found | Session 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:
| Status | Detail | Meaning |
|---|---|---|
| 401 | Invalid heartbeat token | Token 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);
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.