Setup & LicensingLicense Verification
On this page0

Unfamiliar word? See Glossary: words used in these docs. Looking for the tasks in order? Begin at Start Here.

Reference

License Verification

How your instance and the Zonitix licensing server (the store) prove to each other that a license check-in, and the answer to it, are genuine.

What The Signature Protects

The signature works in two directions. It protects us from someone pretending to be your instance in order to claim a plan: the store refuses any check-in whose signature does not match, and never logs the secret. It protects you from someone on the network forging or replaying the store’s answer to add or remove modules. It is not a seal on the software running on your server. We host that server, so the controls there are different ones: you cannot log in to the server itself, and every update is published by us and applied by your administrator.

Forgery Is Refused

A check-in with no signature, or with a wrong one, is refused, and the wrong one is logged against the license key. The secret is never sent back or written to logs.

Connectivity Interruptions

If your instance cannot reach the store, the plan details from the last successful check-in are already saved on your instance and stay in force. A network interruption does not lock you out and does not switch features off.

If something here is not working as you expect, start with When something goes wrong.

The plain explanation is above. This part is for IT and developers: the exact settings, permission names, API addresses and data fields behind it. Closed: select the heading above to open it. While it is closed, your browser’s find-on-page (Ctrl+F or Cmd+F) does not search this part.

Each heartbeat is signed with a per-instance secret using HMAC-SHA256; the Store recomputes the signature and rejects any mismatch.CRYPTOGRAPHIC LICENSE VERIFICATIONInstance signsHMAC-SHA256(payload,per-instance secret)Store recomputessame key, same inputcompare digestsMatch → acceptedmismatch → 403 rejectedsecret never transmitted

HMAC-Signed Heartbeats

At provisioning we issue your instance a license key and a license secret. Your instance signs every heartbeat with that secret using HMAC-SHA256, and the store recomputes the signature to confirm the call really came from your instance. The store signs its reply with the same secret, and your instance checks that signature before it applies any entitlement. This is a symmetric shared secret — there is no RSA key pair and no certificate involved.

Direction

Both — request and reply

Algorithm

HMAC-SHA256

Header

X-MS-Signature

A heartbeat with no signature is refused with 401, and one with a wrong signature with 403 that is logged as an HMAC mismatch against the license key.

Signature Construction

Both sides build the signing string from a fixed field order and HMAC it with your license secret. Order matters: a mismatch in field order produces a mismatched signature, so the array below has to be built in exactly this order wherever it is signed. Neither side writes the secret or the string that is signed to its logs. The store verifies a ten-field payload — the three telemetry values (appVersion, userCount, metrics) covered by the HMAC, where they previously travelled outside it. The instance we ship today signs all ten fields, so its telemetry travels inside the signature. Older instances that predate this change signed only the first seven fields (the identity fields plus timestamp), leaving their telemetry outside the signature; the store no longer accepts that payload (see below).

// The ten-field payload the store verifies (an upgraded instance signs the same):

const dataToSign = [

serviceId || null, licenseKey, hostname || null,

ipAddress || null, customerId || null, companyName || null,

timestamp,

appVersion ?? null, userCount ?? null, metrics ?? null

].map(v => v === undefined ? null : v);

const signature = crypto

.createHmac('sha256', licenseSecret)

.update(JSON.stringify(dataToSign))

.digest('hex');

// Sent as the X-MS-Signature header. The store recomputes and compares;

// on mismatch it returns 403 and logs only the license key + outcome.


// The request body carries the same values (timestamp = unix seconds),

// plus two fields that sit OUTSIDE the ten signed ones:

await fetch(storeUrl + '/api/service/heartbeat', {

method: 'POST',

headers: {

'Content-Type': 'application/json',

'X-MS-Signature': signature

},

// stringify the SAME objects that were signed — do not rebuild them

body: JSON.stringify({

serviceId, licenseKey, hostname, ipAddress, customerId, companyName,

timestamp, appVersion, userCount, metrics,

// unsigned: active users + server size. The store discards this object.

telemetry: { seatsUsed, host: { hostname, cpuCount, ramGb }, recommendedSeats },

// unsigned: fresh random hex per beat; the store echoes it inside its signed reply

downlinkNonce

})

});

Three details decide whether your signature verifies:

  • Absent telemetry is signed as null, not omitted. If your instance cannot read a metric this beat, put null in that slot — the store always signs ten positions.
  • metrics must serialise identically in both places. The store HMACs its own JSON.stringify of the object it parsed out of your body, so the key order you send is the key order it signs. Build the object once and reuse it for the signature and the body; never re-order or re-emit it in between.
  • Five of the six identity fields fall back to null on any falsy value (all but licenseKey, which is signed as-is). An empty-string hostname is signed as null by the store, so send null (or omit the field) rather than "".

metrics has up to three parts, and the whole object is one signed value. fleet is always present: for each platform and agent version, the number of enrolled devices and the number that checked in recently, as counts with no per-device rows; it is null when the counts could not be read. update is a short report of the release the instance runs and the result of its last update, added only when the instance holds one. deployKey is the public half of an SSH key pair made on your server, added only when the server has published one. Each part is described field by field on Heartbeat (License Check-In).

The Reply Is Signed Too

The entitlements the store returns are signed with the same license secret, and your instance verifies them before it writes a single flag. A reply that fails is discarded: the instance logs the rejection and keeps the entitlements it already holds, so a forged or replayed reply can neither unlock a module nor remove one. (The subscription-lapse pause is separate: it runs from the last paid-through date the instance verified, not from a discarded reply.)

// 200 reply (other fields omitted):

{

"config": { "tier": "Advanced", ..., "subscriptionExpiresAt": "2026-09-01T00:00:00.000Z" },

"configSignature": "<hex>", // v1: HMAC over the config, keys sorted

"configSignatureV2": "<hex>", // v2: HMAC over { v: 2, config, issuedAt, seq, nonce }

"downlink": { "v": 2, "issuedAt": 1781756001, "seq": 1781756001000, "nonce": "<your downlinkNonce>" }

}

  • v2 is what a current instance checks. The HMAC must verify, nonce must equal the downlinkNonce this heartbeat sent, and seq must be greater than the last one accepted. The nonce is fresh for every beat, so a reply captured earlier cannot be replayed later.
  • No downgrade. Once an instance has verified a v2 reply, a reply carrying only the older v1 signature is refused; once it has verified any signed reply, an unsigned one is refused.
  • The paid-through date is trusted only from a signed reply. subscriptionExpiresAt sits inside the signed config, so it cannot be moved earlier or later in transit.

As with the request signature, this defends against the network, not against someone with root on the server, which Zonitix hosts.

Ten Fields Are Required

The store accepts only the ten-field signature. The legacy seven-field payload (the one that stopped at timestamp, leaving telemetry outside the HMAC) is refused with 403 INVALID_SIGNATURE. The instance we ship signs all ten fields, so this changes nothing for a current instance.

Seven-Field Signatures Are Refused

An instance still signing only the first seven fields no longer authenticates. It receives no entitlement updates until it is upgraded to sign all ten.

A Temporary Exception Is Ours To Grant

If a pre-upgrade instance ever has to be admitted while it is rolled forward, that is a store-side exception we switch on deliberately, and every heartbeat that uses it is logged against its license key. It is off by default.

Why it changed: telemetry outside the HMAC meant anyone who observed one heartbeat could resend it inside the freshness window with fabricated appVersion, userCount or metrics, poisoning the append-only heartbeat history and the fleet view without ever holding your license secret.

Timestamps Must Strictly Increase

Each heartbeat’s timestamp must be strictly newer than the last one the store accepted for that license. Replaying a heartbeat verbatim — which is exactly what a naive retry after a socket timeout does — returns 403 “Stale heartbeat timestamp” whenever the original request actually landed and you simply never saw the reply. Every retry must take a fresh timestamp and re-sign the payload. A client that caches one signed body and re-posts it will beat successfully once and then fail forever.

The comparison is against the instant your instance signed, not our receive time, so it is immune to network delay — but it does mean the clock on your server has to move forward monotonically. The existing freshness window still applies on top: a timestamp more than 300 seconds either side of store time is rejected with 403 “Request expired”. A server whose clock drifts, or jumps backwards after a correction, will stall its own heartbeats until real time catches up to the last accepted value.

Where The Secret Lives

Your license secret is issued once during the provisioning handshake and stored on your instance. It is used for licensing only — your instance signs its heartbeats with it and verifies the store’s signed replies with it. It is unrelated to the ENCRYPTION_KEY that protects your data at rest, and it grants no access to your data.

Related reference