Renewal nudges that don't nag: driving "expires in N days" from LicenseKind, not a date in your code
A countdown banner is easy to get wrong in two opposite ways: it nags a perpetual customer who has nothing to renew, or it stays silent until a subscription has already lapsed and the app has failed closed to Free. Both bugs come from the same mistake — computing the message from the raw license file instead of from the validated result. This post builds the renewal nudge the right way, off Keyright's LicenseInfo: using Kind to tell a perpetual license apart from a subscription and a trial, DaysRemaining and IsExpiringSoon to decide when to speak, and the fact that an expired subscription validates to Free — so the nudge must fire while the license is still valid, not after. It reads the small amount of logic behind Kind, shows why a perpetual license has a null ExpiryUtc and must never show a countdown, and wires a banner that renews the quietly-expiring customer and leaves everyone else alone.
A renewal nudge is a small feature with a large blast radius for getting it wrong, and it fails in two opposite directions. Nag too eagerly and you show a countdown to a customer who bought a perpetual license and has nothing to renew — the fastest way to teach people to ignore your banners. Speak too late and the subscription has already lapsed, the app has failed closed to Free, and your “renew soon” message arrives as a “you are locked out” message. Both bugs trace to the same root cause: the message was computed from the wrong thing.
This post builds the nudge off the right thing — the validated Keyright LicenseInfo — and the two opposite bugs stop being possible.
The wrong source: the raw license file
The tempting shortcut is to read the expiry straight out of the license JSON and count down to it:
// Don't do this.
var doc = LicenseDocument.Parse(File.ReadAllText(path));
var days = (DateTime.Parse(doc.ExpiryUtc) - DateTime.UtcNow).TotalDays;
if (days < 14) ShowBanner($"Expires in {(int)days} days");
Two things are wrong here, and they are the whole lesson. The expiry has not been verified — doc is just parsed JSON, so an attacker who edits the file moves your banner’s countdown with a text editor. And doc.ExpiryUtc is nullable: a perpetual license has no expiry, so DateTime.Parse(null) throws, and the “fix” most people reach for — defaulting a null expiry to DateTime.MaxValue — silently turns every perpetual customer into someone the banner thinks expires in a few billion days. The information you want does not live in the file in a usable shape. It lives on the result of validating the file.
The right source: LicenseInfo, after validation
KeyrightClient.Validate() returns a LicenseInfo whose relevant surface is small and already answers every question the nudge has:
public LicenseStatus Status { get; } // Valid, Expired, NoLicense, …
public Edition Edition { get; } // Free, Pro, Enterprise
public DateTime? ExpiryUtc { get; } // null == perpetual
public bool IsTrial { get; }
public LicenseKind Kind { get; } // Perpetual | Subscription | Trial
public bool IsValid => Status == LicenseStatus.Valid;
public bool IsPaid => Edition != Edition.Free;
public int? DaysRemaining { get; }
public bool IsExpiringSoon(int withinDays = 14);
The field that makes the nudge clean is Kind. It is derived, not a free-text claim you have to interpret — and because it is derived from the two facts that actually matter, switching on it is safe:
public LicenseKind Kind => IsTrial
? LicenseKind.Trial
: (ExpiryUtc is null ? LicenseKind.Perpetual : LicenseKind.Subscription);
A trial is a trial whatever else is true. Otherwise, a license with an expiry is a renewable Subscription and a license with no expiry is a Perpetual license that must never see a countdown. Three real-world situations, one enum, no null-handling at the call site.
Days remaining, against a clock you trust
DaysRemaining is the other piece, and the important thing about it is which clock it uses. It is not (ExpiryUtc - DateTime.UtcNow) read off the local machine — that is the clock an attacker rolls back, and it is the clock a nudge computed in the UI layer would naively trust. It is computed inside the validated result, against the same trusted now the validator used to decide the license had not already expired (the clock-tamper tolerance that backs this is covered in trusting the clock):
public int? DaysRemaining =>
ExpiryUtc is { } e ? Math.Max(0, (int)Math.Ceiling((e - _now).TotalDays)) : null;
The null for a perpetual license is deliberate and it is your friend: there is no number to show because there is no expiry, and the type system makes you handle that rather than defaulting it to something absurd. IsExpiringSoon(withinDays) wraps the same comparison into the one boolean the nudge actually asks — “are we inside the renewal window?” — with a sensible 14-day default you can widen for an annual plan or narrow for a monthly one.
The nudge: speak before the license lapses
Now the feature writes itself, and it writes itself correctly, because the gate comes first. The nudge only ever considers a license that is still valid and still paid — which is the only state in which “renew soon” is true rather than “you are already locked out”:
public RenewalNotice? GetRenewalNotice(LicenseInfo info, int windowDays = 14)
{
// Fail-closed first. An expired subscription is ALREADY Free here.
if (!info.IsValid || !info.IsPaid)
return info.Status == LicenseStatus.Expired
? RenewalNotice.Lapsed(info.Message) // firmer: feature is off, restore it
: null; // never licensed — a different flow
return info.Kind switch
{
LicenseKind.Perpetual => null, // nothing to renew — stay quiet
LicenseKind.Trial => RenewalNotice.Trial(info.DaysRemaining!.Value),
LicenseKind.Subscription when info.IsExpiringSoon(windowDays)
=> RenewalNotice.Renew(info.DaysRemaining!.Value),
_ => null, // subscription, but not soon — silent
};
}
Read what each branch guarantees. A perpetual customer gets null — no banner, ever, because they have nothing to renew. A subscription customer sees the countdown only inside the window, and because this whole method runs on the IsValid && IsPaid path, the countdown appears while the product is still fully working. The one place an expired license reaches is the IsPaid == false early return, where it becomes a lapsed notice with a firmer message — the feature is off, here is how to restore it — which is a different sentence from the countdown and deliberately so.
The DaysRemaining!.Value dereferences are safe precisely because Kind already proved an expiry exists: a Trial or Subscription always has one, a Perpetual never does and never reaches those branches. The enum you switched on is what makes the null-forgiving operator honest rather than a gamble.
Rendering it: one notice, three sentences
The UI layer never touches a license file, a date, or a null. It renders a RenewalNotice:
var notice = licensing.GetRenewalNotice(client.Validate());
if (notice is { } n)
banner.Show(n.Kind switch
{
NoticeKind.Trial => $"{n.Days} days left in your trial — upgrade to keep your work",
NoticeKind.Renew => $"Your plan renews in {n.Days} days",
NoticeKind.Lapsed => "Your subscription has ended — renew to restore Pro features",
}, action: n.RenewUrl);
No countdown for perpetual. No silent lapse for subscriptions, because the nudge fired in the window before IsPaid flipped to false. No crash on a null expiry, because the null never escaped the validation layer. And no network call for any of it — everything here came off a single offline Validate(), so the banner works on an air-gapped machine that activated once and has not been online since, which is exactly the customer most likely to drift past a renewal.
The takeaway
A renewal nudge is only as good as the thing it reads. Read the raw file and you build a banner that nags perpetual customers, trusts a forgeable date, and crashes on a null expiry. Read the validated LicenseInfo — Kind to tell the three license shapes apart, DaysRemaining computed against a trusted clock, IsExpiringSoon to find the window, and the IsValid && IsPaid gate to make sure you speak before the license fails closed — and the two opposite bugs are not bugs you fixed, they are bugs you made unrepresentable. The nudge talks to the customer who can act on it, and stays quiet for everyone else.
Try Nebula.NET
Harden your .NET code in minutes — start with the free edition.