Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Added

- `ActivateKeyResponse.ExpirationDate` and `FloatingCheckoutResponse.ExpirationDate` (`DateTimeOffset?`): the license expiry returned by `POST /key/activate` and `POST /key/checkout`, or `null` when the license never expires. On checkout this is separate from `ExpiresAt`, the floating seat lease.

## 1.5.0 - 2026-09-09

### Fixed
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,31 @@ if (result.IsSuccess) {
}
```

## License Expiry

`ActivateKey` and `FloatingCheckout` return `ExpirationDate`: when the license expires, as a UTC `DateTimeOffset?`, or `null` if it never expires. On `FloatingCheckout` this is separate from `ExpiresAt`, which is the floating seat lease.

```csharp
var activation = await client.ActivateKey(new ActivateKeyParams {
ProductId = productId,
LicenseKey = licenseKey,
HostId = hostId
});

if (activation.IsSuccess) {
var expiry = activation.Data.ExpirationDate;
if (expiry is null) {
Console.WriteLine("License never expires.");
} else {
var daysLeft = Math.Max(0, (int)Math.Ceiling((expiry.Value - DateTimeOffset.UtcNow).TotalDays));
Console.WriteLine($"License expires {expiry.Value:yyyy-MM-dd} ({daysLeft} days left).");
}
// Persist the last value you received (e.g. in your app settings).
}
```

Store the last `ExpirationDate` you received. An expired license fails activation with the same generic error as an invalid key, so the stored date is what lets your app tell the user the license expired on that date instead of calling the key invalid.

## Machine Identity
Keymint provides utilities to uniquely identify machines for node-locking:

Expand Down
13 changes: 13 additions & 0 deletions Services/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,13 @@ public class ActivateKeyResponse
public string? VersionId { get; set; }
[JsonPropertyName("version")]
public ActivationVersion? Version { get; set; }
/// <summary>
/// When the license expires (UTC), or <c>null</c> if it never expires.
/// An expired license fails activation with the generic invalid-key error,
/// so store the last value you received to tell the user when it expired.
/// </summary>
[JsonPropertyName("expirationDate")]
public DateTimeOffset? ExpirationDate { get; set; }
}

/// <summary>
Expand Down Expand Up @@ -675,6 +682,12 @@ public class FloatingCheckoutResponse
public required int HeartbeatInterval { get; set; }
[JsonPropertyName("metadata")]
public Dictionary<string, object>? Metadata { get; set; }
/// <summary>
/// When the license expires (UTC), or <c>null</c> if it never expires.
/// Not the same as <see cref="ExpiresAt"/>, which is the floating seat lease.
/// </summary>
[JsonPropertyName("expirationDate")]
public DateTimeOffset? ExpirationDate { get; set; }
[JsonPropertyName("currentSessions")]
public int? CurrentSessions { get; set; }
[JsonPropertyName("maxSessions")]
Expand Down
87 changes: 87 additions & 0 deletions Tests/ApiContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,85 @@ public async Task ActivateKey_deserializes_metadata_and_version_fields()
Assert.Equal("pro", ((JsonElement)result.Data.Metadata!["tier"]).GetString());
}

[Fact]
public async Task ActivateKey_deserializes_expiration_date()
{
var handler = new StubHandler(_ => RawJsonResponse(
"{\"code\":0,\"message\":\"License valid\",\"expirationDate\":\"2027-03-01T00:00:00.000Z\"}"));
var sdk = CreateSdk(handler);

var result = await sdk.ActivateKey(new ActivateKeyParams
{
ProductId = "product_123",
LicenseKey = "license_123"
});

Assert.True(result.IsSuccess, result.Error?.Message);
Assert.Equal(new DateTimeOffset(2027, 3, 1, 0, 0, 0, TimeSpan.Zero), result.Data!.ExpirationDate);
Assert.Equal(TimeSpan.Zero, result.Data.ExpirationDate!.Value.Offset);
}

[Theory]
[InlineData("{\"code\":0,\"message\":\"License valid\",\"expirationDate\":null}")]
[InlineData("{\"code\":0,\"message\":\"License valid\"}")]
public async Task ActivateKey_treats_null_or_missing_expiration_date_as_never_expires(string json)
{
var sdk = CreateSdk(new StubHandler(_ => RawJsonResponse(json)));

var result = await sdk.ActivateKey(new ActivateKeyParams
{
ProductId = "product_123",
LicenseKey = "license_123"
});

Assert.True(result.IsSuccess, result.Error?.Message);
Assert.Null(result.Data!.ExpirationDate);
}

[Theory]
[InlineData("\"2027-03-01T00:00:00.000Z\"", true)]
[InlineData("null", false)]
public async Task FloatingCheckout_deserializes_license_expiration_separately_from_seat_lease(string expirationJson, bool hasExpiry)
{
var json = "{\"code\":0,\"message\":\"Seat checked out\",\"sessionId\":\"session_123\"," +
"\"sessionSecret\":\"secret_123\",\"nextNonce\":\"nonce_1\",\"expiresAt\":\"2026-09-08T01:00:00Z\"," +
"\"heartbeatInterval\":60,\"expirationDate\":" + expirationJson + "}";
var sdk = CreateSdk(new StubHandler(_ => RawJsonResponse(json)));

var result = await sdk.FloatingCheckout(new FloatingCheckoutParams
{
ProductId = "product_123",
LicenseKey = "license_123",
HostId = "host_123"
});

Assert.True(result.IsSuccess, result.Error?.Message);
Assert.Equal("2026-09-08T01:00:00Z", result.Data!.ExpiresAt);
if (hasExpiry)
{
Assert.Equal(new DateTimeOffset(2027, 3, 1, 0, 0, 0, TimeSpan.Zero), result.Data.ExpirationDate);
}
else
{
Assert.Null(result.Data.ExpirationDate);
}
}

[Fact]
public void ExpirationDate_round_trips_through_System_Text_Json()
{
var original = new ActivateKeyResponse
{
Code = 0,
Message = "License valid",
ExpirationDate = new DateTimeOffset(2027, 3, 1, 0, 0, 0, TimeSpan.Zero)
};

var roundTripped = JsonSerializer.Deserialize<ActivateKeyResponse>(JsonSerializer.Serialize(original));

Assert.Equal(original.ExpirationDate, roundTripped!.ExpirationDate);
}

[Fact]
public async Task CreateKey_deserializes_bulk_keys()
{
Expand Down Expand Up @@ -184,6 +263,14 @@ private static HttpResponseMessage JsonResponse(object value)
};
}

private static HttpResponseMessage RawJsonResponse(string json)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}

private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
Expand Down
Loading