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

Rate Limits ​

The License Server API implements intelligent rate limiting to protect the service while allowing legitimate usage patterns. Different endpoint categories have different rate limits based on their expected usage frequency.

Rate Limit Categories ​

High Frequency - Validation Endpoints ​

1000 requests / 15 minutes

These endpoints are designed for high-frequency real-time validation:

  • POST /v1/verify-license - Verify license validity
  • POST /v1/validate-license - Validate license for instance
  • POST /v1/track-usage - Report usage metrics
  • POST /v1/usage-report - Get usage history
  • POST /v1/check-update - License-gated software update check (Software Distribution)

Use case: Real-time license checks in your application, usage tracking, monitoring.

Moderate Frequency - Activation Endpoints ​

300 requests / 15 minutes

These endpoints handle license activation and file operations:

  • POST /v1/activate-license - Activate license for instance
  • POST /v1/deactivate-license - Free a seat so it can be activated elsewhere
  • POST /v1/verify-license-file - Verify license (JSON body, despite the name — not a file upload)
  • POST /v1/verify-license-file-base64 - Verify Base64 license file

Use case: License activation during application startup, moving a seat between machines, license file verification.

Additional per-key limit on activation

activate-license and deactivate-license each carry a second, stricter limit on top of the 300/15min per-IP one above: 20 requests / 15 minutes, keyed per license key (not per IP). This exists because the license key alone authorizes activation — without it, one key could be hammered with activation guesses from many different IPs. The two are counted separately (from 1.14.0; before that they shared one count), so an app retrying activation can't use up the requests another machine needs to give its seat back. If you're activating many instances of the same key in a short window (e.g. a bulk rollout), you can hit this limit well before the per-IP one.

Standard Frequency - Public Endpoints ​

500 requests / 15 minutes

Public information endpoints with generous limits:

  • POST /v1/license-export - Get license information (1.21.0+)
  • POST /v1/license-export/file - Download license file (1.21.0+)
  • POST /v1/license-export/offline - Get an RSA-signed license, verifiable offline with only a public key (1.21.0+)
  • GET /v1/export-license/{key}, /{key}/file and /{key}/offline - the same three, deprecated from 1.21.0, sharing one limit with the POST forms
  • GET /v1/public-key - Get the RSA public key used for offline verification

Use case: Exporting license data, downloading license files, offline license verification.

Low Frequency - Management Endpoints ​

100 requests / 15 minutes (Authentication Required)

Sensitive license management operations:

  • POST /v1/issue-license - Create new license
  • POST /v1/revoke-license - Revoke/reactivate license
  • POST /v1/admin/update-license-terms - Update a license's expires_at, max_activations, limits, or lease_seconds
  • DELETE /v1/delete-license - Delete license
  • POST /v1/register-release - Register a software release (Software Distribution)
  • POST /v1/unpublish-release - Unpublish a software release (Software Distribution)

Use case: Issuing, revoking, and deleting licenses; registering and unpublishing software releases.

Admin Reporting Endpoints ​

300 requests / 15 minutes (Authentication Required) — the same numeric limit as the Activation tier above, not lower despite coming after "Low Frequency" on this page; the two just aren't on the same ordinal scale (this one is configurable, the others aren't).

Administrative reporting operations. Unlike every other tier on this page, this limit is configurable via the ADMIN_RATE_LIMIT_MAX environment variable — 300 is just the default:

  • GET /v1/list-licenses - List all licenses
  • GET /v1/list-releases - List your registered software releases (Software Distribution)
  • GET /v1/release/{id} - Fetch a single software release, including its signature (Software Distribution)
  • GET /v1/admin/stats - Get admin statistics
  • GET /v1/recent-activations - Get recent activations
  • GET /v1/list-activations/{key} - List activations for a specific license key
  • GET /v1/admin/license/{key} - Look up a single license's full record
  • POST /v1/admin/update-notes - Update a license's internal notes
  • POST /v1/admin/reissue-token - Reissue an activation token
  • POST /v1/admin/deactivate-by-instance-id - Force-free a seat by instance_id, without its token
  • POST /v1/admin/reset-usage - Reset a license's tracked usage
  • POST /v1/admin/reissue-portal-token - Issue a new end-user portal token (invalidates the old one)
  • GET/POST/PATCH/DELETE /v1/admin/storefront-webhooks... - Manage storefront auto-fulfillment webhooks
  • GET/POST /v1/admin/products, PATCH /v1/admin/products/{id} - List, create and configure your products (1.17.0+)
  • GET /v1/admin/tier-a-status - View the activation-license status
  • GET /v1/admin/build-info - View build/watermark info
  • POST /v1/admin/crash-test - Send a deliberate error to check crash reporting (1.15.0+)
  • GET /v1/billing/status - Check the calling tenant's subscription plan/status (hosted tier)
  • POST /v1/billing/checkout - Start an upgrade to a paid plan (hosted tier)
  • POST /v1/rotate-api-key - Rotate the calling tenant's own API key (hosted tier)
  • DELETE /v1/delete-account - Delete the calling tenant's account (hosted tier)

Use case: Administration dashboards, periodic reporting, auditing per-license device activity.

Other Limits ​

  • GET /v1/admin/report-extract and GET /v1/admin/expiring-licenses — 100 requests / 15 minutes each, per IP. These take their own separate keys (REPORT_EXTRACT_KEY, NOTIFICATIONS_EXTRACT_KEY; see Installation), not the admin key.
  • POST /v1/webhooks/resend (1.17.0+) — 600 requests / 15 minutes per IP. Resend calls it with bounces and complaints, signed by Resend rather than authenticated with a key.
  • The end-user license portal (/portal/:token, no /v1 prefix, only when PORTAL_ENABLED=true) — 300 requests / 15 minutes per IP, and 60 requests / 15 minutes per portal token.

Rate Limit Headers ​

Every API response includes rate limit information in the headers:

http
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 873
HeaderDescription
X-RateLimit-LimitMaximum requests allowed in the time window
X-RateLimit-RemainingRequests remaining in current window
X-RateLimit-ResetSeconds until the window resets (not a timestamp) — at most 900, since every window is 15 minutes

Handling Rate Limits ​

When You Hit the Limit ​

When you exceed the rate limit, you'll receive a 429 Too Many Requests response:

http
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 897
Retry-After: 897

{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded, retry in 15 minutes"
}

Retry-After is the number of seconds until the window resets, the same value as X-RateLimit-Reset, and can be as long as 900. The body has no retryAfter field, and its message is rounded to minutes, so read the header.

Best Practices ​

1. Respect Rate Limits ​

Check the rate limit headers, and once you get a 429, stop sending requests until the window resets instead of retrying straight away:

javascript
// One hold-off for everything. Limits are counted per tier, so if you call
// several tiers, keep one of these per tier instead.
let blockedUntil = 0;

async function makeAPIRequest(url, options) {
  if (Date.now() < blockedUntil) {
    const error = new Error('Rate limited: not sending until the window resets');
    error.status = 429;
    throw error;
  }

  const response = await fetch(url, options);

  if (response.status === 429) {
    // Seconds until the window resets - up to 900.
    const retryAfterSeconds = parseInt(response.headers.get('Retry-After'), 10) || 60;
    blockedUntil = Date.now() + retryAfterSeconds * 1000;
    console.warn(`Rate limit exceeded. Holding off for ${retryAfterSeconds}s`);
    return response; // the caller treats a 429 as "no answer", not a refusal
  }

  // Warn when approaching limit
  const remaining = parseInt(response.headers.get('X-RateLimit-Remaining'), 10);
  if (remaining < 10) {
    console.warn(`Approaching rate limit: ${remaining} requests remaining`);
  }

  return response;
}

2. Back Off on Network Errors and 5xx, Not on 429 ​

Exponential backoff suits short outages: a network error or a 5xx, on requests that are safe to repeat. Only retry the read-only checks (verify-license, validate-license, usage-report): after a lost response, a retried activate-license gets no token, a retried track-usage counts twice, and a retried issue-license issues a second license. Backoff doesn't suit a 429 — retrying after 1, 2 and 4 seconds into a window that resets in up to 15 minutes only spends more requests. Return the 429 to the caller instead (see Error Handling):

javascript
// Read-only checks: repeating one after a lost response does no harm.
const RETRYABLE = new Set(['/verify-license', '/validate-license', '/usage-report']);

class LicenseClient {
  constructor(baseURL) {
    this.baseURL = baseURL;
    this.maxRetries = 3;
  }

  async request(endpoint, options, retryCount = 0) {
    const maxRetries = RETRYABLE.has(endpoint) ? this.maxRetries : 0;
    try {
      const response = await fetch(`${this.baseURL}${endpoint}`, {
        ...options,
        signal: AbortSignal.timeout(10000)
      });

      if (response.status >= 500 && retryCount < maxRetries) {
        const delay = 1000 * Math.pow(2, retryCount);
        console.log(`Server error ${response.status}. Retrying in ${delay}ms (attempt ${retryCount + 1})`);
        await new Promise(resolve => setTimeout(resolve, delay));
        return this.request(endpoint, options, retryCount + 1);
      }

      // 2xx, 4xx and 429 go back to the caller as they are.
      return response;
    } catch (error) {
      // Network error or timeout
      if (retryCount < maxRetries) {
        const delay = 1000 * Math.pow(2, retryCount);
        await new Promise(resolve => setTimeout(resolve, delay));
        return this.request(endpoint, options, retryCount + 1);
      }
      throw error;
    }
  }
}

3. Cache License Verification Results ​

Cache only a result that's valid for your product (check product_uuid; a key for any other product also gets valid: true), and drop the cached result when the server refuses the key:

javascript
class CachedLicenseClient {
  // productUuid: your product's product_uuid, from the issue-license response.
  // cacheTimeout: how long a result is reused without asking (5 minutes).
  // maxStale: how long a result may stand in while the server can't be
  // reached (24 hours) - the limit on time offline.
  constructor(baseURL, productUuid, cacheTimeout = 300000, maxStale = 24 * 3600000) {
    this.baseURL = baseURL;
    this.productUuid = productUuid;
    this.cache = new Map();
    this.cacheTimeout = cacheTimeout;
    this.maxStale = maxStale;
  }

  // No answer: the cached result, while it's recent enough, or noAnswer.
  // Don't treat this as an invalid license.
  noAnswer(cached, status) {
    if (cached && Date.now() - cached.timestamp < this.maxStale) {
      return { ...cached.result, stale: true, checkedAt: cached.timestamp };
    }
    return { noAnswer: true, status };
  }

  async verifyLicense(key) {
    const cacheKey = `verify:${key}`;
    const cached = this.cache.get(cacheKey);

    // Return cached result if still fresh
    if (cached && Date.now() - cached.timestamp < this.cacheTimeout) {
      return cached.result;
    }

    // Make API request
    let response;
    try {
      response = await fetch(`${this.baseURL}/verify-license`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ key }),
        signal: AbortSignal.timeout(10000)
      });
    } catch {
      return this.noAnswer(cached); // network error or timeout
    }

    if (response.status === 429 || response.status >= 500) {
      return this.noAnswer(cached, response.status);
    }

    const result = await response.json();

    if (response.ok && result.valid && result.product_uuid === this.productUuid) {
      this.cache.set(cacheKey, { result, timestamp: Date.now() });
      return result;
    }

    // Refused (404/403), or a license for another product
    this.cache.delete(cacheKey);
    return { ...result, valid: false };
  }
}

A network error or timeout makes fetch throw; catch it in the caller and treat it the same as a 429 or 5xx.

4. Batch Operations When Possible ​

Instead of making many individual requests, batch operations:

javascript
// ❌ Bad: Multiple individual requests
for (const key of licenseKeys) {
  await verifyLicense(key);
}

// ✅ Good: Batch verification (if your app supports it)
const results = await Promise.all(
  licenseKeys.map(key => verifyLicense(key))
);

// ✅ Better: Use rate-aware batching
async function batchVerifyLicenses(keys, batchSize = 10, delayBetweenBatches = 1000) {
  const results = [];
  
  for (let i = 0; i < keys.length; i += batchSize) {
    const batch = keys.slice(i, i + batchSize);
    const batchResults = await Promise.all(
      batch.map(key => verifyLicense(key))
    );
    results.push(...batchResults);
    
    // Add delay between batches to stay within rate limits
    if (i + batchSize < keys.length) {
      await new Promise(resolve => setTimeout(resolve, delayBetweenBatches));
    }
  }
  
  return results;
}

Rate Limiting by IP ​

On a self-hosted server, rate limits are applied per IP address on every tier, with one exception: activate-license and deactivate-license are each additionally limited per license key (see the warning above). On the hosted tier, the License Management and Admin Reporting tiers are counted per account instead (each account gets its own bucket, whatever IP its requests come from); the public tiers are still per IP. For the per-IP limits, this means:

  • Each client IP gets its own rate limit bucket
  • Multiple users behind the same NAT/proxy share limits
  • Load balancers should preserve client IP addresses

Handling Shared IP Scenarios ​

If multiple users share an IP (corporate NAT, shared hosting), consider:

  1. User-based rate limiting (custom implementation)
  2. API key based limits (for authenticated users)
  3. Request higher limits for shared environments

Monitoring Rate Limits ​

Client-Side Monitoring ​

Track your rate limit usage:

javascript
class RateLimitMonitor {
  constructor() {
    this.limits = new Map();
  }

  trackResponse(endpoint, response) {
    const limit = parseInt(response.headers.get('X-RateLimit-Limit'));
    const remaining = parseInt(response.headers.get('X-RateLimit-Remaining'));
    const reset = parseInt(response.headers.get('X-RateLimit-Reset'));
    
    this.limits.set(endpoint, {
      limit,
      remaining,
      reset,
      usagePercent: ((limit - remaining) / limit * 100).toFixed(1)
    });
    
    // Log high usage
    if (remaining < limit * 0.1) { // Less than 10% remaining
      console.warn(`High rate limit usage for ${endpoint}: ${remaining}/${limit} remaining`);
    }
  }

  getLimitStatus(endpoint) {
    return this.limits.get(endpoint);
  }

  getAllLimitStatus() {
    return Object.fromEntries(this.limits);
  }
}

Server-Side Monitoring ​

If you run the License Server, monitor rate limit metrics:

  • Requests per endpoint category
  • Rate limit violations by IP
  • Peak usage times
  • Client retry patterns

Fixed Rate Limits ​

Every tier's limit is a fixed value set in the server implementation, except the Admin Reporting tier, which is configurable via the ADMIN_RATE_LIMIT_MAX environment variable (default 300 requests / 15 minutes).

Need Higher Limits? ​

If your application requires higher rate limits:

  1. Optimize your usage - Cache results, batch requests
  2. Doing a one-time bulk operation? - The License Management tier (100 req/15min) is deliberately tight. There's no self-service way to raise it yet — reach out and we'll help directly. This is a temporary gap, not a policy.
  3. Admin reporting limit - On a self-hosted server you can raise the Admin Reporting tier with ADMIN_RATE_LIMIT_MAX; the other tiers are fixed, self-hosted or not
  4. Use multiple endpoints - Distribute load across endpoint categories

Next Steps ​