A headless service — a worker, a gRPC backend, a scheduled job host — has nowhere to show you its license. There is no About box, no status bar, no settings screen. The one place an operator will ever notice that the key expired last night is the monitoring dashboard, which means the license has to become a health check. The trap is that the obvious wiring — license into the readiness probe — turns a billing event into a crash loop. This post builds the version that does not.
It is about mapping a Keyright license onto ASP.NET Core's health model so that valid is green, expiring is amber and still serving, and expired is red and loud — without ever letting a lapsed license trigger a restart.
Three statuses, and what an orchestrator does with each
ASP.NET Core health checks have exactly three outcomes, and the whole design hinges on using all three rather than the two most people reach for:
public enum HealthStatus { Unhealthy = 0, Degraded = 1, Healthy = 2 }
The reporting middleware maps them to HTTP for you: Healthy and Degraded both return 200, and only Unhealthy returns 503. An orchestrator or load balancer reading the readiness endpoint pulls an instance out of rotation on 503 and leaves it in on 200. So Degraded is the status that lets a check say "something is wrong, but keep serving" — which is precisely the shape of a license inside its grace window. Collapse the model to Healthy/Unhealthy and you are forced to call a grace window either green (nobody sees the clock running out) or red (a still-entitled instance is pulled from rotation). Both are wrong; Degraded is the right answer and it is sitting right there.
The mapping
Keyright's Validate() returns a LicenseInfo whose Status is a LicenseStatus. The health check is the translation table from that enum to HealthStatus:
using Microsoft.Extensions.Diagnostics.HealthChecks;
public sealed class LicenseHealthCheck(LicenseSnapshot snapshot) : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken ct = default)
{
LicenseInfo info = snapshot.Current; // cached; see below — no crypto on this path
var data = new Dictionary<string, object>
{
["status"] = info.Status.ToString(),
["licensee"] = info.Licensee ?? "(none)",
["expiryUtc"] = info.ExpiryUtc?.ToString("O") ?? "(n/a)",
["daysRemaining"] = info.DaysRemaining,
};
HealthCheckResult result = info.Status switch
{
LicenseStatus.Valid when info.IsExpiringSoon
=> HealthCheckResult.Degraded(
$"License valid, expires in {info.DaysRemaining} day(s).", data: data),
LicenseStatus.Valid
=> HealthCheckResult.Healthy("License valid.", data),
// Still entitled, serving on a cached lease inside the grace window.
LicenseStatus.Valid or LicenseStatus.Expired when snapshot.InGraceWindow
=> HealthCheckResult.Degraded(
"Running on cached lease inside grace window.", data: data),
_ => HealthCheckResult.Unhealthy(
$"License not valid: {info.Status}. {info.Message}", data: data),
};
return Task.FromResult(result);
}
}
Expired, Revoked, SignatureInvalid, MachineMismatch, ClockTampered and NoLicense all fall into the _ arm and come out Unhealthy — the service is running unlicensed and the owner needs to know now. The two Degraded arms are the point of the whole exercise: a key that is valid but inside its renewal window, and a key that has lapsed but is still inside a grace window on a cached lease. Both keep the service in rotation and both paint the dashboard amber.
Keep it off liveness — the restart loop
Here is the failure the readiness-probe wiring produces. Liveness answers "is this process wedged, restart it?". Readiness answers "should this instance take traffic?". Put the license on liveness and the sequence is:
- The key expires at midnight. The liveness check goes
Unhealthy, returns 503. - The orchestrator kills the container.
- The replacement starts, reads the same license from the same place, and the liveness check goes
Unhealthyagain. - Go to 2.
You have built a crash loop whose root cause is a billing event, and the restarts bury the one log line that would have told you so. A restart can fix a wedged process; it cannot renew a license. So the license check goes on readiness at most, and preferably on its own endpoint, and never on liveness. Register the checks with tags and split the endpoints by tag:
builder.Services
.AddSingleton<LicenseSnapshot>() // the cached snapshot (next section)
.AddHealthChecks()
.AddCheck<LicenseHealthCheck>("license", tags: ["license"])
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live", "ready"]);
var app = builder.Build();
// Liveness: nothing that a restart cannot fix. No license here.
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live"),
});
// Readiness: the license may ride along, but a Degraded grace window stays 200.
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("ready"),
});
// A dedicated endpoint monitoring scrapes for the amber/red license signal.
app.MapHealthChecks("/health/license", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("license"),
ResponseWriter = WriteLicenseJson, // emit the data bag, below
});
If your platform treats readiness strictly as 200-or-out, leave "license" off the ready predicate as shown and let only /health/license carry it, so a grace window — which is Degraded/200 — can never be the thing that pulls a still-entitled instance. You decide what degraded traffic means; you do not let a probe decide it by killing the box.
The snapshot: don't re-verify crypto on every probe
A readiness probe can fire every second. License state changes at most once a day. Validating a signature on every probe is wasted work and, worse, tempts you into putting a network call on the probe path. Wrap the client in a memoised snapshot that refreshes on a slow cadence, so the health check reads a field:
public sealed class LicenseSnapshot
{
static readonly KeyrightClient License = KeyrightClient.Initialize(new KeyrightOptions
{
Product = "acme-worker",
PublicKeyBase64 = AcmeKeys.LicensePublicKey, // public key only; safe to ship
ServiceUrl = "https://licensing.acme.example",
});
readonly object _gate = new();
LicenseInfo _info = License.Validate();
DateTime _asOf = DateTime.UtcNow;
public LicenseInfo Current
{
get
{
lock (_gate)
{
if (DateTime.UtcNow - _asOf > TimeSpan.FromSeconds(60))
{
_info = License.Validate(); // crypto + any phone-home here, off the probe
_asOf = DateTime.UtcNow;
}
return _info;
}
}
}
public bool InGraceWindow => _info.Status == LicenseStatus.Valid && _info.IsExpiringSoon
|| (_info.Status == LicenseStatus.Expired && _info.DaysRemaining > -7);
}
Now CheckHealthAsync is a constant-time read of _info, the probe never blocks on the network, and the once-a-minute refresh is where a lease renewal or background phone-home happens — out of the probe's path, which is the other reason the check must never sit on liveness. (KeyrightClient already caches a verified result internally; the snapshot adds the refresh cadence and the single place to read it.)
What operators actually read
The data dictionary on each HealthCheckResult is the payload that makes this useful rather than a coloured dot. Emit it as JSON from the license endpoint:
static Task WriteLicenseJson(HttpContext ctx, HealthReport report)
{
ctx.Response.ContentType = "application/json";
var entry = report.Entries["license"];
var payload = new
{
status = entry.Status.ToString(), // Healthy | Degraded | Unhealthy
detail = entry.Description,
data = entry.Data, // licensee, expiryUtc, daysRemaining
};
return ctx.Response.WriteAsJsonAsync(payload);
}
Scrape daysRemaining into your metrics and you get an alert that fires three weeks before a perpetual-plus-maintenance key lapses, not three weeks after. Keep the licensee and status fields; do not put the raw license key, the signature, or anything a renewal nudge would carry into an endpoint that may be less guarded than the app itself.
Test the grace window, not just valid and expired
The test that matters is the one the readiness-probe design would have failed: an expired-but-in-grace key must come back Degraded and keep serving, and a hard-expired key must come back Unhealthy without the liveness endpoint ever noticing.
[Fact]
public async Task Expiring_key_is_degraded_and_stays_in_rotation()
{
var snapshot = FakeSnapshot.With(status: LicenseStatus.Valid,
expiryUtc: DateTime.UtcNow.AddDays(5)); // IsExpiringSoon
var check = new LicenseHealthCheck(snapshot);
var result = await check.CheckHealthAsync(new HealthCheckContext());
Assert.Equal(HealthStatus.Degraded, result.Status); // amber, not red
// Degraded is HTTP 200 → the instance is NOT pulled from rotation.
}
[Fact]
public async Task Revoked_key_is_unhealthy()
{
var snapshot = FakeSnapshot.With(status: LicenseStatus.Revoked);
var result = await new LicenseHealthCheck(snapshot)
.CheckHealthAsync(new HealthCheckContext());
Assert.Equal(HealthStatus.Unhealthy, result.Status); // 503 on the license endpoint
}
Pair those with an assertion that the "live" predicate never resolves the "license" check, so a future refactor cannot quietly move the license onto liveness and rebuild the crash loop. Drive the whole thing against a fake issuing service if you want the refresh round-trip covered too.
The takeaway
A headless service's license is only ever visible on the dashboard, so it has to be a health check — but the three HealthStatus levels exist for a reason, and license state uses all of them. Valid is Healthy and 200; valid-but-expiring or lapsed-inside-grace is Degraded and still 200, so the instance keeps serving while the dashboard turns amber; everything else is Unhealthy and 503. Keep the check off liveness so a billing event can never trigger a restart loop, read it from a memoised snapshot so a per-second probe never re-runs crypto, and emit daysRemaining so the alert fires before the key lapses, not after. The license stops being an invisible time bomb in a service with no UI and becomes one more coloured line ops already know how to watch.



