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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Unreleased

- Add `expirationDate` (ISO 8601 string, or `None` when the license never expires) to the `activate_key` and `floating_checkout` response types. On checkout it is separate from `expiresAt`, the seat lease. Additive; no other fields changed.
- Document reading the expiry, computing days left, and storing the last value in the README.

## 2.5.0 - 2026-09-09

- Synchronize licensing, floating-session, offline-signing, error, and customer contracts with the current API.
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,30 @@ if result and 'key' in result:
print(f"Created Key: {result['key']}")
```

## License Expiry

`activate_key` and `floating_checkout` return `expirationDate`: the license expiry as an ISO 8601 string (e.g. `"2027-03-01T00:00:00.000Z"`), or `None` when the license never expires. On `floating_checkout`, `expiresAt` is the seat lease, not the license expiry.

```python
from datetime import datetime, timezone
import math

result = sdk.activate_key({'productId': product_id, 'licenseKey': license_key, 'hostId': host_id})

expiration = result.get('expirationDate')
if expiration is None:
print('License never expires')
else:
expires_at = datetime.fromisoformat(expiration.replace('Z', '+00:00'))
seconds_left = (expires_at - datetime.now(timezone.utc)).total_seconds()
days_left = max(0, math.ceil(seconds_left / 86400))
print(f'License expires {expires_at:%Y-%m-%d} ({days_left} days left)')

# Store the last value you received (e.g. in your app's settings file).
```

Store the last `expirationDate` you received. An expired license fails activation with the same generic invalid-key error as a wrong key, so the stored date is how your app can 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
7 changes: 6 additions & 1 deletion keymint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ def activate_key(self, params: ActivateKeyParams, idempotency_key: str | None =

:param params: Activation parameters including productId, licenseKey, and optional hostId.
:param idempotency_key: Optional unique identifier to ensure request idempotency.
:return: Activation success message and licensee information.
:return: Activation success message and licensee information. Includes
``expirationDate``: the license expiry as an ISO 8601 string, or ``None``
when the license never expires. An expired license fails activation with
the same generic invalid-key error, so store the last value you received.
"""
return self._handle_request('POST', '/key/activate', params, idempotency_key=idempotency_key)

Expand All @@ -98,6 +101,8 @@ def floating_checkout(self, params: FloatingCheckoutParams, idempotency_key: str
:param params: Parameters for checking out the license.
:param idempotency_key: Optional unique identifier to ensure request idempotency.
:returns: The checkout response containing sessionId and sessionSecret.
``expiresAt`` is the seat lease; ``expirationDate`` is the license expiry
as an ISO 8601 string, or ``None`` when the license never expires.
"""
return self._handle_request('POST', '/key/checkout', params, idempotency_key=idempotency_key)

Expand Down
8 changes: 7 additions & 1 deletion keymint/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ class ActivateKeyResponse(TypedDict):
versionId: str | None
version: dict[str, Any] | None
allowedHosts: list[str] | None
# License expiry as an ISO 8601 string (e.g. "2027-03-01T00:00:00.000Z"),
# or None when the license never expires.
expirationDate: str | None

class DeactivateKeyParams(TypedDict):
productId: str
Expand Down Expand Up @@ -223,9 +226,12 @@ class FloatingCheckoutResponse(TypedDict):
sessionId: str
sessionSecret: str
nextNonce: str
expiresAt: str
expiresAt: str # Floating seat lease expiry (not the license expiry)
heartbeatInterval: int
metadata: dict[str, Any] | None
# License expiry as an ISO 8601 string, or None when the license never
# expires. Separate from ``expiresAt``, which is the seat lease.
expirationDate: str | None
currentSessions: int | None
maxSessions: int | None
licenseeName: str | None
Expand Down
33 changes: 33 additions & 0 deletions tests/test_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,36 @@ def test_nested_error_preserves_server_message(mock_post):

with pytest.raises(KeyMintApiError, match="Customer email already exists in team"):
KeyMint("admin_test").create_key({"productId": "product_123"})


@pytest.mark.parametrize("expiration_date", ["2027-03-01T00:00:00.000Z", None])
@patch("keymint.requests.post")
def test_activate_key_returns_expiration_date(mock_post, expiration_date):
mock_post.return_value = response({
"code": 0, "message": "License valid", "expirationDate": expiration_date,
})

result = KeyMint("client_test").activate_key({"productId": "product_123", "licenseKey": "license_123"})

assert mock_post.call_args.args[0].endswith("/key/activate")
assert "expirationDate" in result
assert result["expirationDate"] == expiration_date


@pytest.mark.parametrize("expiration_date", ["2027-03-01T00:00:00.000Z", None])
@patch("keymint.requests.post")
def test_floating_checkout_returns_expiration_date_separate_from_lease(mock_post, expiration_date):
mock_post.return_value = response({
"code": 0, "message": "Checked out", "sessionId": "session_123",
"sessionSecret": "secret_123", "nextNonce": "nonce_123",
"expiresAt": "2026-10-26T12:05:00.000Z", "heartbeatInterval": 60,
"metadata": None, "expirationDate": expiration_date,
})

result = KeyMint("client_test").floating_checkout({
"productId": "product_123", "licenseKey": "license_123", "hostId": "host_123",
})

assert mock_post.call_args.args[0].endswith("/key/checkout")
assert result["expirationDate"] == expiration_date
assert result["expiresAt"] == "2026-10-26T12:05:00.000Z"
Loading