XCAICX docs Product Contact

Metering & billing

What gets counted, where the counters live, how they survive an outage, and how they become an invoice line. Commercial terms are per programme — this page is the mechanism.

What counts as one inference#

One inference per module per frame. Running detect and ppe over a 30fps stream is 60 metered calls per second.

That rule is deliberately simple, because a metering rule an integrator cannot predict from reading their own code is a metering rule that generates disputes.

Consequences worth knowing before you design your pipeline:

  • Enabling a module you do not consume still costs. Subset per stream with stream_config.modules rather than enabling everything on the engine.
  • detect_every_n = 3 cuts metered calls by roughly two-thirds, because inference does not run on skipped frames. Tracking coasts across them, so events still fire.
  • A module that finds nothing is still counted. The work was done.
  • PPE internally re-runs detection to find people; that is not double-counted. One frame with ppe enabled is one ppe inference.

Where counters live#

state_dir/usage.json, checkpointed periodically and on clean shutdown, and restored at engine start.

Careful

Counters resume across reboots. A reboot loop is not a way to get free inference. This is also why state_dir must be persistent — see state_dir requirements.

Writes are tmp-file + fsync + rename, so power loss mid-write cannot corrupt the file.

Read the live counters at any time:

c
xcaicx_license_info info;
info.struct_size = sizeof info;
xcaicx_engine_license_info(engine, &info);
printf("%llu inferences, %llu stream-seconds this period\n",
       (unsigned long long)info.metered_inferences,
       (unsigned long long)info.metered_stream_seconds);
python
print(eng.license.metered_inferences, eng.license.metered_stream_seconds)

Hard quota#

lim.api_calls > 0 in the token makes the quota a hard stop on the device:

c
if (!engine_->charge_inference(m)) continue;   /* quota exhausted */

The engine logs once at ERROR and stops running that module. It does not crash and does not stop the video path — the customer's cameras keep recording, they just stop analysing.

Note

That is the correct failure mode for something bolted onto safety-critical infrastructure. A quota is a commercial limit, and a commercial limit must never become a safety event.

lim.api_calls = 0 means unmetered — typical for perpetual OEM royalty SKUs, where the unit was paid for at manufacture and there is no recurring relationship with the end user.

Getting usage to the invoice#

On each heartbeat the SDK seals the current period into state_dir/usage-spool.jsonl and POSTs it to /v1/usage.

text
┌────────────┐  seal    ┌────────────────────┐  POST /v1/usage
│ usage.json │ ───────► │ usage-spool.jsonl  │ ────────────────►  server
└────────────┘          └────────────────────┘
                                 ▲                        │
                                 └──── cleared only ──────┘
                                      after 200 OK

Careful

The spool is only cleared after the server acknowledges. Unshipped usage is unbilled revenue, so it survives outages, reboots and month-long disconnections. A camera offline for six weeks bills correctly on the day it reconnects.

Replays are deduped server-side on (device_id, period_start, period_end), so a device that never received an acknowledgement can safely resend without double-billing the customer. The response tells you which happened:

json
{"accepted": 3, "duplicates": 1}

Controlling the heartbeat#

c
cfg.cloud_endpoint    = "https://licensing.xcaicx.com";
cfg.heartbeat_seconds = 3600;    /* 0 = default 3600 */

Or drive it yourself from your own scheduler, which is what most firmware ends up doing so the radio wakes once for everything:

c
cfg.heartbeat_seconds = 86400;   /* effectively off */
...
xcaicx_engine_heartbeat(engine); /* forces a round-trip now */

xcaicx_engine_heartbeat() ships usage and pulls the revocation list. It returns XCAICX_OK and does nothing when the engine is offline.

With cfg.offline = 1 the SDK never opens a socket at all. Usage still accrues to the spool and can be collected by your own tooling or by xcaicxctl for reconciliation — see Air-gapped deployment.

What a usage record contains#

Counts, and nothing else:

FieldMeaning
period_start, period_endUnix seconds; the dedupe key with device_id
inferencesTotal metered calls in the period
framesFrames submitted
stream_secondsWall-clock seconds of open streams
eventsEvents emitted
per-module countsdetect, defect, ppe, anpr

Note

No frames, no crops, no bounding boxes, no imagery of any kind leaves the camera. If a customer asks what the heartbeat sends — and industrial customers do ask — this table is the complete answer.

How a period is rated#

GET /v1/admin/invoice/{customer_id}?period_days=30 produces the invoice. Four line types are computed:

  1. Per-unit, from activations. Devices activated inside the window, grouped by SKU, priced at the volume tier for that quantity.
  2. Per-unit, from direct licences. Licences issued without an activation record still bill per unit.
  3. Metered inference. Usage beyond the included allowance, rated per thousand.
  4. Annual maintenance. On perpetual SKUs, once a year (period_days >= 365).

The allowance pools#

The included allowance is multiplied by the customer's active unit count and compared against their total usage, rather than being enforced per camera.

Note

One busy loading bay and one idle stockroom should net out. Anything sold as an "API allowance" that does not pool will generate support tickets on day one — every buyer expects pooling, because that is what the word means everywhere else they have met it.

Volume tiers#

Per-unit price steps down at quantity breaks. The tier is chosen by the quantity in the rated period, so an OEM activating a large run gets the tier for that run.

Prices are data, not logic: a deployment overrides the whole catalogue with XCAICX_PRICING_JSON without touching code, because the first thing every real customer does is negotiate.

json
{"edge-complete": {"tiers": [[1, 590], [100, 470]]}}

The override is a partial — it changes the named fields of the named SKUs and leaves everything else alone.

Reconciling#

bash
xcaicxctl usage LIC-D3ACF708DB6048AB     # totals for one licence
xcaicxctl fleet                          # every activated unit
xcaicxctl invoice CUST-CAMCO --days 30   # rated period
xcaicxctl batch-status B-2026-1C4417D5   # purchased vs activated

When a customer disputes a number, the order of investigation that actually finds the answer is:

  1. batch-status — are units activated more than they think? Look for reflashes counted as new devices, which would mean the fingerprint is unstable.
  2. fleet — any weak_binding units, or duplicate device ids?
  3. usage — does the licence's total match the camera's local usage.json? A gap means unshipped spool, not lost usage.

Next#

Commercial software. Use requires a valid XCAICX licence token. Questions an integrator cannot answer from this page belong in an email to [email protected] — and, usually, in a fix to this page.

© 2026 AZMX AI · xcaicx.com