Issuing Licenses
Most licenses are issued in the console, one at a time, or by a storefront when a sale completes. This page covers the console first, then the same work through the API, for scripts and bulk issuing.
Prerequisites
- In the console: a hosted account, or the self-hosted bundle's console (see Installation).
- Through the API: your API address and an admin key: on a hosted account, your account's API key from Settings → API access; self-hosted,
ADMIN_API_KEY. - A plan for your products and tiers: which Product IDs you'll use, and what each tier allows.
Issue a license in the console
(Hosted, or the self-hosted bundle's console.)
- Go to Licenses and choose Issue license.
- Fill in Product ID, Tier and Issued to. All three are required.
- Check the expiry. Expires on defaults to a year from today, at 23:59 Eastern time; change Expires on, At or Time zone if you need to. For a license that never expires, tick Perpetual (never expires) instead, which hides the expiry fields (Console 1.14.2 or later; self-hosted, update to the current bundle).
- Set Max activations, the number of machines that can hold the license at once. It defaults to 1.
- Optionally, set Free inactive machines, Notes and Limits (optional).
- Choose Issue license.
The "License issued" page shows the Key, the Product UUID and the customer's Portal link. The portal link is shown only once, so send it to your customer now, with the key. Using the Console describes every field.
Console fields and API fields
Each field on the issue form sets one field of POST /v1/issue-license:
| Console field | API field |
|---|---|
| Product ID | product_id |
| Tier | tier |
| Issued to | issued_to |
| Perpetual (never expires) | No expires_at: a license that never expires |
| Expires on, At, Time zone | expires_at, one ISO 8601 date-time. The console converts your time zone |
| Max activations | max_activations |
| Free inactive machines | lease_seconds: the number of days × 86400. Never sends nothing, which means no lease |
| Notes | notes |
| Limits (optional), Features | limits: one key per filled-in field, such as users or api_calls_per_day, and features as an array |
Edit terms uses POST /v1/admin/update-license-terms: Perpetual (never expires) sends expires_at: null, and Unlimited seats sends max_activations: null.
Find, revoke and change a license in the console
(Hosted, or the self-hosted bundle's console.)
- Find one: filter Licenses by Customer, License key, Product or Status. See Finding a customer or license.
- Change its terms: Edit terms on the license's page changes the expiry, seats, inactive-machine setting and limits. See Changing terms.
- Revoke it: Revoke on the license's page; Unrevoke undoes it. See Revoking, reactivating and deleting.
- Free a seat: Deactivate next to the machine, under Activations. See Activations.
Automating this with the API
Everything above can be done through the admin API, which is how you issue licenses in bulk or from your own back-office systems. Call it from a server, never from code that ships to customers: the admin key can issue and revoke every license on your account.
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/v1already on the end,https://license-cloud-api.casazium.com/v1, so that value replaceshttps://your-license-api.com/v1as a whole: don't add a second/v1. Notlicense-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 acceptADMIN_API_KEY. - Self-hosted: your own server's address. The self-hosted bundle answers on
http://localhost:3001on the machine running it, sohttp://localhost:3001/v1replaceshttps://your-license-api.com/v1.
Issuing through the API
Issue a Simple License
tier, product_id, and issued_to are required. expires_at is optional — omit it (or send null) for a license that never expires. It must be a full ISO 8601 date-time (2027-12-31 alone is rejected with a 400), and a date in the past isn't rejected: the license is issued already expired, and verifying or activating it returns 403 License has expired.
curl -X POST https://your-license-api.com/v1/issue-license \
-H "Authorization: Bearer $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"tier": "pro",
"product_id": "your-product",
"issued_to": "customer@company.com",
"expires_at": "2027-12-31T23:59:59.000Z"
}'Response:
{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"status": "issued",
"product_uuid": "b3f1c9a0-1234-4abc-9def-e2d4f5a6b7c8",
"portal_token": "a1d5163d1e1e4cd7e0e46f577dbeb6f2"
}product_uuid is this product's real, immutable identity - see Product IDs are per-tenant, not global. This response is the natural place to capture it once and hard-code it in your app - also shown on the console's license detail page afterward, if you need it again.
The server doesn't echo back tier, product_id, issued_to, expires_at, limits, max_activations or lease_seconds — if you need those values afterward, keep them in your own records at issuance time, or fetch them later with the admin-only GET /v1/admin/license/:key. The public export (POST /v1/license-export, or the deprecated GET /v1/export-license/:key) returns only tier, product_id, product_uuid, expires_at and limits (plus key and a signature), not issued_to or max_activations.
portal_token is a real credential, not a value to log or store casually — it's the customer's own key into the end-user license portal, returned once, here. Capture it now if you want to hand it to this customer (in whatever delivery email/receipt you already send); it cannot be retrieved again except via POST /v1/admin/reissue-portal-token, which also invalidates the old one.
Issue a License with Usage Limits
Create a license with specific usage constraints:
curl -X POST https://your-license-api.com/v1/issue-license \
-H "Authorization: Bearer $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"tier": "enterprise",
"product_id": "your-product",
"issued_to": "enterprise@company.com",
"expires_at": "2027-12-31T23:59:59.000Z",
"limits": {
"users": 500,
"api_calls_per_day": 100000,
"projects": 50
},
"max_activations": 10
}'License Parameters
Required Fields
| Field | Type | Description |
|---|---|---|
tier | string | License tier. Not enforced against a fixed list server-side — any non-empty string is accepted — but the published API contract's tier values are free, pro, enterprise |
product_id | string | Product identifier — a label scoped to your own tenant, not a global claim; see Product IDs are per-tenant, not global |
issued_to | string | Customer identifier (email, company name, etc.) |
Optional Fields
| Field | Type | Description | Default |
|---|---|---|---|
expires_at | string (ISO 8601) | Expiration timestamp — omit or send null for a license that never expires | null (never expires) |
limits | object | Usage limits — see allowed keys below | {} (no limits) |
max_activations | number | Maximum concurrent activations | 1 |
lease_seconds | number or null | 1.14.0+: free the seat of a machine that hasn't checked in for this many seconds (86400 to 31536000, 1 to 365 days) when another machine needs one — see Seats that free themselves | null (seats are permanent) |
notes | string | Internal notes | null |
License Limits
The limits object's keys are validated against a fixed allowlist — anything else is rejected with 400 {"error": "Unknown limit key: <key>"}:
{
"limits": {
"users": 100, // Maximum users
"seats": 10, // Maximum seats
"admins": 5, // Maximum admin users
"projects": 5, // Number of projects
"environments": 3, // Number of environments
"tenants": 1, // Number of tenants
"api_calls_per_day": 10000, // API calls per day
"rate_limit_rps": 100, // Requests per second
"concurrent_sessions": 50, // Concurrent sessions
"features": ["advanced-reporting", "sso"] // Feature-flag style gate - array of flag names, not a count
}
}You don't need to set all of these — include only the keys relevant to your product. There is no storage/bandwidth-related limit key, and no way to add a custom, arbitrary metric name — metric values used with Usage Tracking must be one of the keys above.
Bulk License Issuance
Using a Script
For issuing multiple licenses, create a script:
const axios = require('axios');
class LicenseIssuer {
constructor(apiUrl, adminKey) {
this.apiUrl = apiUrl;
this.headers = {
'Authorization': `Bearer ${adminKey}`,
'Content-Type': 'application/json'
};
}
async issueLicense(licenseData) {
try {
const response = await axios.post(
`${this.apiUrl}/issue-license`,
licenseData,
{ headers: this.headers }
);
return response.data;
} catch (error) {
const wrapped = new Error(`Failed to issue license: ${error.response?.data?.error || error.message}`);
// Keep the HTTP status (undefined for a network error) and Retry-After
// so callers like issueLicenseWithRetry() below can act on them.
wrapped.status = error.response?.status;
wrapped.retryAfterSeconds = Number(error.response?.headers?.['retry-after']) || undefined;
throw wrapped;
}
}
async issueBulkLicenses(licenses) {
const results = [];
for (const licenseData of licenses) {
try {
const license = await this.issueLicense(licenseData);
results.push({ success: true, license, data: licenseData });
console.log(`✓ Issued license for ${licenseData.issued_to}: ${license.key}`);
} catch (error) {
results.push({ success: false, error: error.message, data: licenseData });
console.error(`✗ Failed to issue license for ${licenseData.issued_to}: ${error.message}`);
}
}
return results;
}
}
// Usage
const issuer = new LicenseIssuer('https://your-license-api.com/v1', process.env.ADMIN_API_KEY);
const licenses = [
{
tier: 'free',
product_id: 'your-product',
issued_to: 'customer1@company.com',
expires_at: '2027-12-31T23:59:59.000Z'
},
{
tier: 'pro',
product_id: 'your-product',
issued_to: 'customer2@company.com',
expires_at: '2027-12-31T23:59:59.000Z',
limits: { users: 50 }
}
// Add more licenses...
];
issuer.issueBulkLicenses(licenses)
.then(results => {
const successful = results.filter(r => r.success).length;
const failed = results.filter(r => !r.success).length;
console.log(`\nCompleted: ${successful} successful, ${failed} failed`);
});CSV Import Script
Import licenses from a CSV file:
import csv
import requests
import os
from datetime import datetime, timezone
class BulkLicenseIssuer:
def __init__(self, api_url, admin_key):
self.api_url = api_url
self.headers = {
'Authorization': f'Bearer {admin_key}',
'Content-Type': 'application/json'
}
def issue_license(self, license_data):
response = requests.post(
f'{self.api_url}/issue-license',
json=license_data,
headers=self.headers
)
response.raise_for_status()
return response.json()
def import_from_csv(self, csv_file_path):
results = []
with open(csv_file_path, 'r') as file:
reader = csv.DictReader(file)
for row in reader:
try:
# Convert CSV row to license data
license_data = {
'tier': row['tier'],
'product_id': row['product_id'],
'issued_to': row['issued_to']
}
# expires_at is optional -- omit or leave blank in the
# CSV for a license that never expires
if row.get('expires_at'):
license_data['expires_at'] = row['expires_at']
if row.get('max_activations'):
license_data['max_activations'] = int(row['max_activations'])
# Add limits if present -- keys must come from the
# server's fixed allowlist (see the License Limits
# section above)
limits = {}
for key in ['users', 'api_calls_per_day', 'projects']:
if row.get(key):
limits[key] = int(row[key])
if limits:
license_data['limits'] = limits
# Issue the license
license = self.issue_license(license_data)
results.append({
'success': True,
'license': license,
'row': row
})
print(f"✓ Issued license for {row['issued_to']}: {license['key']}")
except Exception as error:
results.append({
'success': False,
'error': str(error),
'row': row
})
print(f"✗ Failed to issue license for {row['issued_to']}: {error}")
return results
# Usage
issuer = BulkLicenseIssuer(
'https://your-license-api.com/v1',
os.getenv('ADMIN_API_KEY')
)
# Example CSV format (expires_at may be left blank for a license that never expires):
# tier,product_id,issued_to,expires_at,max_activations,users,api_calls_per_day
# free,my-product,user1@company.com,2027-12-31T23:59:59.000Z,1,10,1000
# pro,my-product,user2@company.com,2027-12-31T23:59:59.000Z,3,50,10000
results = issuer.import_from_csv('licenses.csv')
successful = len([r for r in results if r['success']])
failed = len([r for r in results if not r['success']])
print(f"\nCompleted: {successful} successful, {failed} failed")Viewing, revoking and reactivating through the API
View License Details
Fetch a license's full record, with your API key:
curl https://your-license-api.com/v1/admin/license/7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28 \
-H "Authorization: Bearer $ADMIN_API_KEY"It returns the license's status, tier, product, issued_to, dates, limits and usage, max_activations, lease_seconds and notes: see GET /v1/admin/license/:key. The public export (POST /v1/license-export, or the deprecated GET /v1/export-license/:key) is a different thing: a smaller, signed shape for offline verification, without issued_to or max_activations.
Revoke a License
Temporarily disable a license:
curl -X POST https://your-license-api.com/v1/revoke-license \
-H "Authorization: Bearer $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"revoked": true
}'Reactivate a License
Re-enable a revoked license. A revoked license that has also passed its expires_at can't be reactivated this way — the server returns 403 {"error": "License has expired"}; extend it with POST /v1/admin/update-license-terms first.
curl -X POST https://your-license-api.com/v1/revoke-license \
-H "Authorization: Bearer $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
"revoked": false
}'Best Practices
License Key Security
- Never log license keys in plain text
- Store admin API keys securely
- Use HTTPS for all license operations
- Implement audit logging for license operations
License Organization
- Use consistent product_id naming
- Include meaningful customer identifiers in issued_to
- Document your licensing tiers and limits
- Track license issuance in your customer database
Bulk Operations
- Process licenses in batches to respect rate limits
- Implement retry logic for failed operations
- Log all operations for audit purposes
- Validate data before issuing licenses
Monitoring
- Track license issuance rates
- Monitor for unusual patterns
- Set up alerts for failed operations
- Regular audits of active licenses
Troubleshooting
Common Issues
403 Unauthorized:
# Verify your admin API key
curl -H "Authorization: Bearer $ADMIN_API_KEY" \
https://your-license-api.com/v1/admin/statsRate Limit Exceeded:
- Implement exponential backoff
- Batch operations appropriately
- Consider requesting rate limit increases
Invalid Parameters:
- Check required fields are present
- Validate date formats (ISO 8601)
- Ensure limit values are whole numbers, 0 or more (
featuresis an array of strings)
Error Handling Example
Uses the LicenseIssuer from Using a Script above, which keeps the HTTP status on error.status:
async function issueLicenseWithRetry(licenseData, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const license = await issuer.issueLicense(licenseData);
return { success: true, license };
} catch (error) {
// Retry only a 429 or a 503 (the server answered and issued
// nothing). Not a network error, a timeout or a 502/504 from a
// proxy: the license may have been issued with the response lost,
// and a retry would issue a second one - check list-licenses first.
// Not other 4xx errors: the request itself is wrong.
const retryable = error.status === 429 || error.status === 503;
if (!retryable || attempt === maxRetries) {
return { success: false, error: error.message };
}
// A 429 means waiting for the window to reset: Retry-After seconds,
// up to 15 minutes. That's fine in a batch job like this one.
const delay = error.status === 429
? (error.retryAfterSeconds ?? 60) * 1000
: 1000 * Math.pow(2, attempt);
console.log(`HTTP ${error.status}, retrying in ${delay}ms (${attempt}/${maxRetries})...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}Next Steps
- Using the Console for everything else you can do with a license there
- Learn about license activation tracking
- Set up usage monitoring
- Explore the API reference for complete endpoint documentation