Skip to content
License Server v1.29.0 · released 30 Oct 2026release notes →
Skip to content

Usage Tracking ​

Guide to implementing usage tracking and reporting for license compliance and analytics.

Overview ​

Usage tracking lets you increment named usage counters against a license and check them against the limits set when the license was issued. It's deliberately simple: one metric per call, and the metric name must already be a key in that license's limits object — there's no way to track an ad-hoc metric the license wasn't issued with.

Allowed metric names

The full set of limit keys the server recognizes (set via limits when issuing a license, and the only valid metric values for tracking) is: users, seats, admins, projects, environments, tenants, api_calls_per_day, rate_limit_rps, concurrent_sessions, features. Anything else is rejected. features is a list of feature names, not a count, so it can be set as a limit but not tracked: POST /v1/track-usage refuses it as a metric (1.10.0+).

Your API address

The examples use https://your-license-api.com as a stand-in for your License Server's API address. Put yours in its place:

  • Hosted account: https://license-cloud-api.casazium.com. The console's Settings → API access → API base URL shows it with /v1 already on the end, https://license-cloud-api.casazium.com/v1, so that value replaces https://your-license-api.com/v1 as a whole: don't add a second /v1. Not license-cloud.casazium.com: that's the console, and an API call sent there gets a JSON 404 naming the right address. Where an example sends $ADMIN_API_KEY, use your account's API key from the same Settings → API access section instead: a hosted account doesn't accept ADMIN_API_KEY.
  • Self-hosted: your own server's address. The self-hosted bundle answers on http://localhost:3001 on the machine running it, so http://localhost:3001/v1 replaces https://your-license-api.com/v1.

With the SDK ​

@casazium/license-sdk makes these calls for a JavaScript app. Setting it up is in Using the SDK; it's an ES module, so run these snippets as one (a .mjs file, or "type": "module" in your package.json).

js
import { CasaziumLicenseClient } from '@casazium/license-sdk';

const client = new CasaziumLicenseClient({
  baseUrl: 'https://your-license-api.com/v1',
});

// `token` is the activation token activate() returned on this machine
// (see License Activation). Adds 1 to the metric's running total.
const { usage } = await client.trackUsage(key, token, 'api_calls_per_day', 1);

// Every metric the license has a limit for, as it stands now.
const report = await client.getUsageReport(key);
for (const m of report.metrics) {
  console.log(`${m.metric}: ${m.used} of ${m.limit}, ${m.remaining} left`);
}
  • trackUsage(key, token, metric, increment = 1) sends one metric per call and resolves { ok, metric, usage }, usage being the new total. It throws on a refusal, with the server's message and err.status: for example trackUsage failed with 403: Usage limit exceeded for metric: api_calls_per_day, which means none of the increment was applied. The error responses are the same as without the SDK.
  • The SDK never retries trackUsage(): if the server counted the usage but the response was lost, a retry would count it twice. After a 429 or a 5xx, or a request that never reached the server, the increment wasn't applied and you can send it again later; after a timeout it may have been, so don't.
  • getUsageReport(key) resolves the snapshot shown under Checking Usage Status: valid, status, expires_at and metrics. It answers even for a revoked or expired license, so check valid.

For a high-frequency metric, add up events locally and send the total now and then, as Batching Local Increments does. The console shows each limit with its usage so far on the license's page.

The SDK doesn't cover resetting usage, and neither does the console. Use the API: POST /v1/admin/reset-usage, with your API key, from a server you control.

Without the SDK (curl or any language) ​

The rest of this section is the API the SDK calls, for an app in another language, or JavaScript without the SDK. Client Examples has complete Python, Java and Go clients.

Basic Usage Tracking ​

Reporting a Single Metric ​

bash
curl -X POST https://your-license-api.com/v1/track-usage \
  -H "Content-Type: application/json" \
  -d '{
    "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
    "token": "9f2c1e4a7b3d6f805c1a2b3d4e5f6789",
    "metric": "api_calls_per_day",
    "increment": 1
  }'

token is the opaque activation token returned by POST /v1/activate-license — proof the caller holds a real activation for this key, not just the key itself (the key alone isn't a secret; it's meant to live in a distributed app or public page source). increment is optional and defaults to 1. It adds to the metric's running total server-side — it isn't an absolute value.

Response:

json
{
  "ok": true,
  "metric": "api_calls_per_day",
  "usage": 1501
}

usage is the new running total for that metric after applying the increment, not a percentage or a snapshot of every metric.

Error responses:

StatusBodyCause
400{"error": "..."}Malformed request: a missing field, or increment below 1 (Increment must be >= 1)
404{"error": "License key not found"}Key doesn't exist
403{"error": "License is not active"}Revoked or otherwise inactive
403{"error": "License is expired"}Past expires_at
403{"error": "Invalid activation token"}token doesn't match a real activation for this key
403{"error": "Metric not allowed: <metric>"}metric isn't a key in this license's limits
403{"error": "Usage limit exceeded for metric: <metric>"}The increment would push usage past the metric's limit
429{"error": "Too Many Requests", ...}Rate limited; see Retry-After
503{"error": "Failed to track usage due to a concurrent update - please retry"}Lost a race with another update to the same license; nothing was applied

Note the limit-exceeded case is a hard rejection — the increment is not applied, and usage is not returned. To track multiple metrics for the same event (e.g. an API call that also counts toward a per-tenant quota), make one track-usage call per metric.

The 4xx responses other than 429 are refusals: sending the same request again gets the same answer. A 429 or 5xx, or a request that never reached the server, means the increment wasn't applied, so it can be sent again later. A timeout is different: the server may have counted the increment and only the response was lost, so sending it again could count it twice. The examples below don't resend after a timeout, accepting an undercount.

Usage is a running total that never resets on its own

Each metric's usage only goes up. Nothing resets it daily, monthly or at renewal, whatever the metric's name suggests (api_calls_per_day included). An admin resets it with POST /v1/admin/reset-usage. If you want a daily quota, call that on your own schedule.

Usage Tracking Implementation ​

Basic Usage Tracker ​

javascript
class UsageTracker {
  constructor(licenseKey, activationToken, apiUrl) {
    this.licenseKey = licenseKey;
    this.activationToken = activationToken; // from POST /activate-license
    this.apiUrl = apiUrl;
  }

  // Report usage for a single metric. `metric` must already be a key in
  // this license's `limits` (see the allowed-metrics list above).
  // Never throws. Resolves to one of:
  //   { outcome: 'recorded', usage }   - applied; usage is the new total
  //   { outcome: 'refused', error }    - 4xx other than 429; resending won't help
  //   { outcome: 'no-answer', timedOut, error } - 429, 5xx, network
  //                                      error or timeout
  async trackUsage(metric, increment = 1) {
    let response;
    try {
      response = await fetch(`${this.apiUrl}/track-usage`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          key: this.licenseKey,
          token: this.activationToken,
          metric,
          increment
        }),
        signal: AbortSignal.timeout(30000)
      });
    } catch (error) {
      return { outcome: 'no-answer', timedOut: error.name === 'TimeoutError', error: error.message };
    }

    const body = await response.json().catch(() => ({}));
    if (response.ok) {
      return { outcome: 'recorded', usage: body.usage };
    }
    const noAnswer = response.status === 429 || response.status >= 500;
    return {
      outcome: noAnswer ? 'no-answer' : 'refused',
      timedOut: false,
      error: body.error || `HTTP ${response.status}`
    };
  }
}

// Usage -- activationToken comes from a prior POST /activate-license call
const tracker = new UsageTracker(
  process.env.LICENSE_KEY,
  activationToken,
  'https://your-license-api.com/v1'
);

await tracker.trackUsage('api_calls_per_day');
await tracker.trackUsage('concurrent_sessions', 3);

Batching Local Increments ​

Since each API call reports exactly one metric, high-frequency events (e.g. per-request API-call tracking) are usually better batched locally and flushed periodically, rather than calling track-usage on every single event. Each flush sends only what was recorded since the last one, and nothing when that's zero:

javascript
class BatchedUsageTracker extends UsageTracker {
  constructor(licenseKey, activationToken, apiUrl, flushIntervalMs = 60000) {
    super(licenseKey, activationToken, apiUrl);
    this.pending = new Map();        // metric -> increment not yet sent
    this.stopped = new Set();        // metrics the server won't accept
    this.timer = setInterval(() => this.flush(), flushIntervalMs);
  }

  // Accumulate locally instead of calling the API immediately
  record(metric, amount = 1) {
    if (this.stopped.has(metric)) return;
    this.pending.set(metric, (this.pending.get(metric) || 0) + amount);
  }

  // Never rejects: trackUsage() doesn't throw.
  async flush() {
    const toSend = Array.from(this.pending.entries());
    this.pending.clear();

    for (const [metric, increment] of toSend) {
      if (increment < 1) continue;
      const result = await this.trackUsage(metric, increment);

      if (result.outcome === 'no-answer' && !result.timedOut) {
        // A 429, 5xx or no connection: not counted, so put it back for the
        // next flush
        this.pending.set(metric, (this.pending.get(metric) || 0) + increment);
      } else if (result.outcome === 'no-answer') {
        // A timeout: the server may have counted it already, so don't send
        // it again - an undercount, not a double one
        console.warn(`${metric}: ${increment} may not have been recorded (${result.error})`);
      } else if (result.outcome === 'refused') {
        if (result.error.startsWith('Usage limit exceeded')) {
          // None of this batch was applied. Whether to block the
          // underlying action is your call (see Best Practices).
          console.warn(`${metric}: over the limit, ${increment} not recorded`);
        } else {
          // Bad token, metric not in this license, or the license is no
          // longer active: no retry will change that.
          console.error(`${metric}: ${result.error}; no longer tracking it`);
          this.stopped.add(metric);
        }
      }
    }
  }

  stop() {
    clearInterval(this.timer);
    return this.flush(); // final flush
  }
}

// Usage
const batched = new BatchedUsageTracker(
  process.env.LICENSE_KEY,
  activationToken,
  'https://your-license-api.com/v1'
);

// Called on every API request, but only sent to the server once a minute
app.use((req, res, next) => {
  batched.record('api_calls_per_day');
  next();
});

A whole batch is refused when it would go over the limit, even if part of it would fit. If that matters, flush more often so batches stay small.

Checking Usage Status ​

GET-ing usage back isn't how this works — POST /usage-report returns a current snapshot of every metric the license has limits for, not a call you make to track something:

bash
curl -X POST https://your-license-api.com/v1/usage-report \
  -H "Content-Type: application/json" \
  -d '{"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28"}'

Response:

json
{
  "valid": true,
  "status": "active",
  "expires_at": "2025-12-31T23:59:59.000Z",
  "metrics": [
    {
      "metric": "api_calls_per_day",
      "used": 1501,
      "limit": 10000,
      "remaining": 8499,
      "exceeded": false
    },
    {
      "metric": "users",
      "used": 25,
      "limit": 100,
      "remaining": 75,
      "exceeded": false
    }
  ]
}

This is a live snapshot at the moment you call it — there's no date-range, granularity, or historical time-series option, and no CSV/PDF export. If you need historical usage trends, you'll need to poll this endpoint on your own schedule and store the results yourself.

javascript
class UsageStatusChecker {
  constructor(licenseKey, apiUrl) {
    this.licenseKey = licenseKey;
    this.apiUrl = apiUrl;
  }

  async getUsageReport() {
    const response = await fetch(`${this.apiUrl}/usage-report`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ key: this.licenseKey }),
      signal: AbortSignal.timeout(30000)
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(`Usage report failed: ${error.error}`);
    }

    return response.json();
  }

  async checkForWarnings(thresholdPercent = 80) {
    const report = await this.getUsageReport();
    const warnings = [];

    // usage-report answers 200 even for a revoked or expired license.
    if (!report.valid) {
      warnings.push({ level: 'critical', metric: null, message: `License is not valid (status: ${report.status}, expires_at: ${report.expires_at})` });
    }

    for (const m of report.metrics) {
      if (m.limit <= 0) continue;
      const percentage = (m.used / m.limit) * 100;

      if (m.exceeded) {
        warnings.push({ level: 'critical', metric: m.metric, message: `${m.metric} limit exceeded (${m.used}/${m.limit})` });
      } else if (percentage >= thresholdPercent) {
        warnings.push({ level: 'warning', metric: m.metric, message: `${m.metric} at ${percentage.toFixed(1)}% of limit` });
      }
    }

    return warnings;
  }
}

// Usage
const checker = new UsageStatusChecker(
  process.env.LICENSE_KEY,
  'https://your-license-api.com/v1'
);

const warnings = await checker.checkForWarnings();
warnings.forEach(w => console.warn(`[${w.level}] ${w.message}`));

Best Practices ​

1. Batch high-frequency events ​

Don't call track-usage once per request for high-volume metrics — accumulate locally and flush periodically (see BatchedUsageTracker above). Every call is a real HTTP round-trip against the High Frequency rate-limit tier (1000 req/15min).

2. Handle the limit-exceeded case explicitly ​

A 403 Usage limit exceeded for metric means the increment was not applied — decide up front whether that should block the underlying action (hard enforcement) or just log a warning (soft enforcement); the server doesn't make that choice for you.

3. Poll usage-report, don't assume track-usage tells you everything ​

track-usage's response only reflects the one metric you just incremented. If you need to check status across all of a license's metrics (e.g. to show a usage dashboard), call usage-report instead.

4. Privacy and compliance ​

  • Only track metrics you actually need for license enforcement
  • Respect data retention policies for any historical data you store yourself (the server doesn't retain usage history beyond the current running total, which only an admin reset brings back down)

Next Steps ​