Skip to content

License key

Writes are gated by an offline license key that the host operator configures. This page explains how to obtain a key, how to install it, how to confirm it is working, what the application does with it, and how to read its log lines. Pricing is announced at launch; see Plans for what each plan covers.

Checkout requires your Entra tenant GUID: the key is bound to that tenant and will not validate against another one (see §5, the tenantId claim). Fulfillment is manual in this phase: a key is emailed to you, addressed to the checkout’s technical contact, within 1 business day of the order.

A 30-day Enterprise trial key is available for evaluation before you buy; it carries every Enterprise capability and a 0-day grace period (see §6), so it stops working cleanly at day 30 rather than degrading gradually.

If you are a licensee, the source for the version your key covers is provided to you along with the key; see the license page for what the Fair Core License lets you do with it.

Pull the published image first (ghcr.io/codewithlaszlo/exonaut:<version>), then set the key as configuration. The app reads it from License:Key, which .NET’s configuration system also accepts as the environment variable License__Key (double underscore, the standard ASP.NET Core section separator).

Terminal window
export License__Key="<the compact JWS your vendor emailed you>"
Terminal window
dotnet user-secrets --project src/ExchangeApi.Web set "License:Key" "<the compact JWS>"
Terminal window
docker run -e License__Key="<the compact JWS>" ghcr.io/codewithlaszlo/exonaut:<version>

Azure Container Apps (Key Vault secretRef)

Section titled “Azure Container Apps (Key Vault secretRef)”

Store the key as a Key Vault secret, then reference it from the container app’s secrets and env blocks, the same pattern used for the IPPS certificate:

Terminal window
az keyvault secret set --vault-name $KV --name exonaut-license --value "$LICENSE_KEY"
secrets: [
{ name: 'license-key', keyVaultUrl: 'https://${kv}.vault.azure.net/secrets/exonaut-license', identity: 'system' }
]
...
env: [
{ name: 'License__Key', secretRef: 'license-key' }
]

See Azure Container Apps for the full Bicep. Changing the key later is a secret change: update the Key Vault secret, then roll a new revision so the running containers pick it up (env-var and secret changes never apply to an already-running revision).

On startup the app logs this summary line once, always at Information level, whether or not a valid key was found:

License: plan={Plan} source={Source} status={Status} customer={Customer} licenseId={LicenseId} expiresOn={ExpiresOn} inGrace={InGrace}

{Plan} is Community, Pro, or Enterprise; {Status} is one of unlicensed, development-default, invalid, tenant mismatch, not-yet-valid, active, grace, or expired. Do not grep for this line at Warning, it is always Information; the separate warnings logged for a missing, invalid, mismatched, or expiring key are their own log lines, at Warning level, see §9 Troubleshooting.

In the Explorer (/explorer/), a plan pill sits next to the readiness dot in the topbar. It reads your plan name only: the anonymous /explorer/auth-config.json payload carries just plan (status, expiry, grace, and source are commercial state and stay off that endpoint), so the pill has no amber grace state and its tooltip is just {plan} plan. To see status, expiry, or grace, check the startup License: plan=... status=... inGrace=... summary line (always Information) or the Warning-level log lines in §9 Troubleshooting.

Validation is entirely local and entirely offline:

  • The key is a compact JWS, signed ES256 (ECDSA P-256).
  • It is verified against a public key embedded in the application binary, one per signing-key generation (see §8). No private key, and no customer data, ever leaves your host to make this check.
  • The app never makes a network call to validate a license. There is no phone-home, no activation server, and no usage reporting (see §10 Privacy).

The claims the app reads from a valid key:

Claim Meaning
iss / aud / typ Fixed values identifying the token as an exonaut license key
sub Customer identifier
jti License id (lic_ followed by 20 hex characters)
plan pro or enterprise (never community, a paid key always grants a paid plan)
features Extra feature flags on top of the plan’s defaults
tenantId The Entra tenant GUID the key is bound to; required to issue any key, including a trial key
nbf / exp The validity window
grace Days the plan keeps working past exp, 0 to 90

Any claim the app doesn’t recognize is ignored, so a future key format stays backward compatible.

Evaluated fresh on every request, from the already-parsed key, not on a timer:

  • Before nbf (minus a 5-minute clock-skew allowance): status not-yet-valid, plan Community.
  • Between nbf and exp: status active, the key’s plan is in effect.
  • Between exp and exp + grace: status grace, the key’s plan keeps working, a warning is logged once.
  • After exp + grace: status expired, plan Community.

Your key’s grace period (0 to 90 days) is set at issuance; ask your vendor contact if you’re not sure what yours is. Trial keys carry a 0-day grace, so they stop cleanly at exp.

Renewal is a new key with a new exp, installed the same way as the original (§3). Because a plan change is a config change, expect a new revision on Container Apps, or a restart anywhere else. There is no in-place “extend” operation; the old key simply stops being current once the new one is installed.

The application embeds one or more vendor public keys (Licensing/LicenseValidator.VendorPublicKeysPem) so a signing-key rotation can ship a “Current” and a “Next” key ahead of the cutover. As of this release, exactly one signing-key generation is embedded:

Key generation Valid from version Status
Initial (kid TBD) v1.1.0 (first licensed release) Not yet issued; the embedded placeholder in this repository does not validate any real key

This table will grow a row per rotation once the first real signing key ships. A key you were issued always validates against the app version it was issued for or later, unless your vendor tells you otherwise for a specific rotation.

The app never crashes on a bad, expired, or mismatched key: it logs a warning and degrades to Community. Log lines are verbatim from src/ExchangeApi.Web/Licensing/LicenseState.cs.

Symptom Log line What it means
No key configured, in Development, caller auth not configured No License:Key configured, Development fallback grants the {Plan} plan. Set License:Key (env License__Key) for a real license in other environments. Only when AzureAd:TenantId/AzureAd:ClientId are both unset, which is exactly when Program.cs picks DevPermissiveAuthHandler over real JWT bearer auth.
No key configured, in Development, caller auth configured No License:Key configured; the Development fallback plan was NOT granted because AzureAd:TenantId/ClientId are configured, so this host authenticates real callers. Set License:Key (env License__Key) for a licensed plan; plan degraded to Community. AzureAd:TenantId/ClientId are set, so this host validates real caller tokens even though the environment is Development (for example, the published image run with ASPNETCORE_ENVIRONMENT=Development). The Development convenience does not apply; install a real key.
No key configured, outside Development, or in Development with caller auth configured (no license log line beyond the warning above, if any; auth-config endpoint’s license.plan reads "Community") Set License:Key / License__Key.
Key doesn’t parse or verify License key failed validation ({Error}), plan degraded to Community. {Error} names the specific failure: a garbled or truncated key, a signature that doesn’t match the embedded vendor public key (wrong or unsigned key), a missing plan claim, plan claim missing or not a paid plan, exp claim missing, or nbf claim missing. Re-copy the key exactly as emailed, with no added whitespace or line breaks.
Key signed for a different tenant License key {LicenseId} is bound to tenant {LicenseTenant} but this host expects tenant {ExpectedTenant}, tenant mismatch, plan degraded to Community. Your key’s tenantId claim doesn’t match this host’s AzureAd:TenantId (or ExchangeOnline:TenantId as a fallback). Keys are one-tenant; contact your vendor if you need to move a deployment to a different tenant.
Key not valid yet License key {LicenseId} is not yet valid (nbf {NotBefore:u} is in the future), plan degraded to Community. The key’s nbf is in the future (beyond a 5-minute clock-skew allowance). Check the host clock, or that you weren’t sent next month’s key early.
Key active License key {LicenseId} is active, plan {Plan}. Working as expected, logged once at Information level.
Key expired, inside grace License key {LicenseId} expired {Expires:u} and is now in its grace period, plan {Plan} keeps working until {GraceEnd:u}. Renew before the grace period ends (see §7).
Key expired, grace over License key {LicenseId} expired {Expires:u} and its grace period has ended, plan degraded to Community. Install a renewed key.
An unexpected parsing failure License key parsing failed unexpectedly, plan degraded to Community. A defensive catch-all; the underlying exception is logged alongside it. Should not happen with a key issued by the vendor’s signer; report it if it does.

A write your plan doesn’t cover returns 402 Payment Required, not 403, so it’s easy to tell apart from an auth problem:

{
"error": {
"code": "PlanRequired",
"message": "This operation requires the Enterprise plan; this host is licensed for Community. Set License:Key (env License__Key) to unlock it. Reads and ?dryRun=true previews stay available.",
"target": "plan:Enterprise",
"innerError": {
"currentPlan": "Community",
"requiredPlan": "Enterprise",
"policy": "OrganizationConfig.ReadWrite",
"licenseStatus": "unlicensed"
}
},
"correlationId": "..."
}

innerError.licenseStatus is the same status string as §9 above, so you can tell “no key” apart from “key expired” apart from “key valid but this plan doesn’t cover this policy” from the error body alone, without checking the startup log.

Validation never leaves your host. The app does not contact the vendor to activate, renew or report on a key, and it sends no telemetry. The only data involved is the set of claims listed in §5, and those stay in your configuration and your own logs.