On this page0
Unfamiliar word? See Glossary: words used in these docs. Looking for the tasks in order? Begin at Start Here.
Reference
Heartbeat (License Check-In)
The heartbeat is the check-in your instance sends to the Zonitix licensing server (the store) every five minutes. The answer tells your instance which features your plan includes.
When Your Instance Cannot Check In
The check-in runs automatically in the background. If your instance cannot reach the store, nothing is switched off: the list of features from the last successful check-in is already saved in your instance’s own database and stays in force. The failed attempt is recorded, and the next successful check-in refreshes the list. A network interruption on its own never removes a feature. Features change when the store returns a new, verified answer. Separately, paid modules pause once a subscription has been lapsed for more than 14 days and the store has answered your instance since; the exact rule is under What Happens When A Subscription Lapses, further down this page.
License Check Interval
Failure Visibility
What Happens When A Subscription Lapses
Every verified reply carries a signed paid-through date. Paid modules pause only once that date is more than 14 days past and the store has answered your instance since that date; your data and the core features stay available throughout, and an instance that cannot reach the store is never paused by this rule. The full rule, what stays available, who is warned and how it is restored are on License Administration.
When A Module Leaves Your Plan
A plan change arrives as a verified reply that switches a module’s flag off. From then on the module’s pages and API refuse the request, and its scheduled background work stops running as well: scheduled report runs, compliance evidence collection, trend snapshots and digests, AlignSync automatic reconciliation and SaaS discovery, Security Lab scans and the vulnerability feed, cloud-mapping syncs, finance budget alerts and digests, and new recruitment interview reminders. A job is skipped only when its flag is explicitly off; a flag that has never synced or cannot be read leaves the job running.
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.
// 1. Generate a payload with a FRESH timestamp (unix seconds)
const payload = { "timestamp": 1781756000, "licenseKey": "MS-...", ... }
// 2. Sign the ordered fields with SHA256-HMAC using your License Secret
// (all 10 fields are signed; a 7-field signature is refused — see License Verification)
const signature = crypto.createHmac('sha256', secret).update(JSON.stringify(dataToSign)).digest('hex');
// 3. Send it with a fresh random downlinkNonce (outside the signature)
// 4. The store's reply is signed too. The instance verifies it, then applies the features
Response: { "licenseType": "Advanced", "config": { "aws_mapping": true, ... },
"configSignature": "...", "configSignatureV2": "...",
"downlink": { "v": 2, "issuedAt": 1781756001, "seq": ..., "nonce": "<echoed>" } }
Everything One Beat Carries
This is the whole request, field by field. The first table is the signed part: ten values, signed together in this order (metrics is one value with up to four parts). The request also has one header, X-MS-Signature, which holds the signature.
| Field | What it holds |
|---|---|
serviceId | The id the store gave this instance when it was provisioned. |
licenseKey | Your license key. It selects the license whose secret the signature is checked with. |
hostname | The host name the licensing program sees on your server. |
ipAddress | Always the fixed text 127.0.0.1. The instance does not put its real address in the payload. |
customerId | Your customer id, set when the server was provisioned. |
companyName | Your company name, set when the server was provisioned. |
timestamp | The send time, in whole seconds (Unix time). |
appVersion | The version of the Zonitix software the instance runs. |
userCount | One number: how many rows the user table holds, whatever their status. No names or addresses. |
metrics.fleet | Only where you run our device agents. For each platform and each agent version: how many enrolled devices are on it (total) and how many of those checked in recently (healthy; within the last 45 minutes unless the server is configured otherwise). Counts only; no device names, serial numbers or owners. Null when the count could not be read. |
metrics.update | Sent only when the instance has an update report. A short report: the release name it runs (in the form v1.2.3), the channel (stable or main), two true/false flags copied from the server’s update settings, and the result of the last update. That result is empty, or four values: one word from a fixed list (such as applied, up-to-date, rolled-back, failed or timed-out), the release names after and before it, and the time. |
metrics.subscriptionLapse | Sent only after your subscription has ended (in the 14 days after the paid-through date, or later). A short report: whether the instance is in grace or past it, the paid-through date, the date the grace period ends, two true/false values (whether pausing is switched on, and whether premium features are paused now) and two lists of plan feature names: those paused now and those that will pause when grace ends. A paid-up instance does not send it. |
metrics.deployKey | Sent only when your server has published one. The public half of an SSH key pair made on your server, in the exact form ssh-ed25519 followed by 68 characters; anything else is not sent. The private half stays on the server host and is not given to any container. |
Four more values travel in the same request outside the signature:
| Field | What it holds |
|---|---|
telemetry.seatsUsed | One number: active user accounts, not counting the Zonitix support account. |
telemetry.host | The host name again, the number of CPU cores and the memory in whole gigabytes. |
telemetry.recommendedSeats | A number computed from the CPU and memory figures. |
downlinkNonce | A fresh random value. The store repeats it inside its signed reply, so a reply can only answer the request that asked for it. |
Nothing else is in the request. It holds no user names, no email addresses, no device records, no documents and no audit-log entries.
What The Store Keeps
- On your instance’s record, the latest value of: host name, the address field as sent, customer id, company name, app version, user count, the
metrics.fleetcounts, themetrics.updatereport and themetrics.subscriptionLapsereport (cleared when a beat no longer carries one). Each beat overwrites the one before. - The
metrics.subscriptionLapsereport is also kept from a beat the store refuses because the subscription ended, so the Zonitix team can see which features your instance has paused. - One history row per beat with the time, the address field, the host name, the app version and the user count.
- The
telemetrygroup is not kept: the store’s request check drops any field it does not name, and it does not name that one. - Any other part of
metricsis dropped before anything is written.
Verifying A Signature And The Settings Table
A failed heartbeat is written to the audit log as LICENSE_SYNC_FAILED. A five-minute timer runs the heartbeat and updates the instance’s local license settings (key/value rows in SecuritySetting). The signature is an HMAC-SHA256, hex-encoded, over the JSON of a ten-element array in a fixed order. It is not computed over the payload object, so a verifier must rebuild the same array:
// Verifying a heartbeat signature (Node.js):
const crypto = require('crypto');
function verifyHeartbeat(payload, signature, secret) {
// the ten signed fields, in this order; a missing value is signed as null
const dataToSign = [
payload.serviceId, payload.licenseKey, payload.hostname, payload.ipAddress,
payload.customerId, payload.companyName, payload.timestamp,
payload.appVersion ?? null, payload.userCount ?? null, payload.metrics ?? null,
].map((v) => (v === undefined ? null : v));
const expected = crypto.createHmac('sha256', secret)
.update(JSON.stringify(dataToSign)).digest('hex');
const a = Buffer.from(signature, 'utf8');
const b = Buffer.from(expected, 'utf8');
// length check first — timingSafeEqual THROWS on a size mismatch
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// SecuritySetting model (Sequelize, table: security_settings):
sequelize.define('SecuritySetting', {
key: { type: DataTypes.STRING, primaryKey: true },
value: { type: DataTypes.JSONB },
description: { type: DataTypes.TEXT, allowNull: true }
}, { tableName: 'security_settings', timestamps: true });
What Every Beat Must Satisfy
The store applies these checks in the order listed. Every failure answers JSON with a machine-readable code. Only the license row is a verdict about your license; every other 4xx is about the request or a pending approval, and 503 is a fault on our side. Signature construction itself is documented in full on the License Verification page.
| Rule | Failure |
|---|---|
| No more than 10 check-ins a minute from one IP. Applied before any other check, so the license was not evaluated: back off and retry. | 429 · RATE_LIMITED |
timestamp (unix seconds) within 300s of store time. The license is not evaluated; the response carries serverTime so you can see the drift. | 403 · CLOCK_SKEW |
X-MS-Signature present. | 401 · MISSING_SIGNATURE |
| A license with this key exists and the signature matches the 10-field payload. Seven-field signatures are refused. An unknown key gets the same answer as a bad signature, so the endpoint does not reveal which keys exist. | 403 · INVALID_SIGNATURE |
License ACTIVE, account open, instance approved, subscription unexpired. Checked only after the signature verified, and before the approval and timestamp rows below. The refusal carries a revocation signed with your license secret, so it cannot be forged in transit or replayed. The instance does not act on the refusal itself: it keeps the entitlements it already holds until the subscription-lapse rule applies (What Happens When A Subscription Lapses, in the plain part above). | 403 · LICENSE_INACTIVE, ACCOUNT_CLOSED, SERVICE_INACTIVE, SUBSCRIPTION_EXPIRED |
| A re-provision of the instance is awaiting operator approval. Not a verdict about your license: no revocation, keep what you hold and retry. | 403 · SERVICE_PENDING |
timestamp strictly greater than the last one accepted for this license. | 403 · STALE_TIMESTAMP |
| A fault on our side (database, configuration). Not a verdict about your license; retry on the normal schedule. | 503 · STORE_UNAVAILABLE |
The monotonic-timestamp rule is a replay defence, and it has one consequence worth planning for: a retry must be re-signed with a new timestamp, never re-sent byte-for-byte. The baseline it compares against is set the moment your instance is provisioned, so the very first heartbeat after the handshake also has to be later than that — on a server with a correct clock this is automatic.
What A Removed Module Answers
When a module leaves your plan (described in the plain part above, under When A Module Leaves Your Plan), its pages and API refuse with a 403.
config.retrievalToken Is Not Returned
The heartbeat response hands back config wholesale, which meant the credential that exchanges for your plaintext license secret was being re-broadcast to anything that could read a response body — a proxy log, a TLS-terminating middlebox, a compromised instance — every five minutes. It is now stripped from the response. Any tooling that read the retrieval token out of a heartbeat reply will find it undefined and must stop doing so; the token belongs only to the provisioning handshake. Nothing else in config changed — feature flags and kill_switch are returned exactly as before.