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

- `expirationDate` on `ActivateKeyResponse` and `FloatingCheckoutResponse`: the license expiry as an ISO 8601 string, or `null` when the license never expires. Typed as optional (`expirationDate?: string | null`) for compatibility with older servers. On checkout it is separate from `expiresAt`, the floating seat lease.

## 1.5.0 - 2026-09-09

### Fixed
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,27 @@ Keymint provides utilities to uniquely identify machines for node-locking:
|-------------------------|--------------------------------------------------|
| `verifyWebhookSignature`| Verifies the signature of a webhook request payload. |

## License Expiry

`activateKey` and `floatingCheckout` return `expirationDate`: when the license expires, as an ISO 8601 string, or `null` if it never expires. On `floatingCheckout` it is separate from `expiresAt`, which is the floating seat lease. The field is optional in the types because older API servers may omit it.

```typescript
const res = await sdk.activateKey({ productId, licenseKey, hostId });

if (res.expirationDate) {
const daysLeft = Math.max(0, Math.ceil((new Date(res.expirationDate).getTime() - Date.now()) / 86_400_000));
console.log(`License expires on ${res.expirationDate} (${daysLeft} days left)`);
} else if (res.expirationDate === null) {
console.log('License never expires');
}

// Store the last value you received (e.g. in your app's settings).
// `undefined` means an older server didn't send the field, so keep the previous value.
if (res.expirationDate !== undefined) saveLastExpirationDate(res.expirationDate);
```

Store the last `expirationDate` your app 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 their license expired on that date instead of calling the key invalid.

## Idempotency

All mutating SDK calls (such as creating keys, activations, customer management, etc.) support idempotency keys to safely retry requests in case of network drops. Pass a unique key (recommended: UUID v4) as the optional request options:
Expand Down
12 changes: 11 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ export interface ActivateKeyResponse {
message: string; // Activation status message (e.g., "License valid")
licenseeName?: string; // Optional: Name of the licensee (updated field name)
licenseeEmail?: string; // Optional: Email of the licensee (updated field name)
/**
* When the license expires, as an ISO 8601 date string, or `null` if it never expires.
* Optional because older API servers may omit it.
*/
expirationDate?: string | null;
metadata?: Record<string, any> | null; // Optional: Custom dictionary attached to the license key.
versionId?: string | null; // Optional: Associated product version ID.
version?: { // Optional: Detailed product version information.
Expand Down Expand Up @@ -393,9 +398,14 @@ export interface FloatingCheckoutResponse {
sessionId: string;
sessionSecret: string;
nextNonce: string;
expiresAt: string;
expiresAt: string; // Floating seat lease expiry (not the license expiry).
heartbeatInterval: number;
metadata?: Record<string, any>;
/**
* When the license itself expires, as an ISO 8601 date string, or `null` if it never expires.
* Separate from `expiresAt`, which is the floating seat lease. Optional because older API servers may omit it.
*/
expirationDate?: string | null;
currentSessions?: number;
maxSessions?: number | null;
licenseeName?: string;
Expand Down
56 changes: 56 additions & 0 deletions tests/contracts.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,59 @@ test('nested API errors preserve the actionable server message', async () => {
);
});
});

function jsonResponder(path, body) {
return (request, response) => {
assert.equal(request.method, 'POST');
assert.equal(request.url, path);
response.setHeader('content-type', 'application/json');
response.end(JSON.stringify(body));
};
}

for (const expirationDate of ['2027-03-01T00:00:00.000Z', null]) {
test(`activateKey passes through expirationDate (${expirationDate})`, async () => {
await withServer(jsonResponder('/key/activate', {
code: 0, message: 'License valid', expirationDate, metadata: null
}), async baseUrl => {
const result = await new KeyMint('client_test', baseUrl).activateKey({
productId: 'product_123', licenseKey: 'license_123', hostId: 'host_123'
});
assert.equal(result.code, 0);
assert.ok('expirationDate' in result);
assert.equal(result.expirationDate, expirationDate);
});
});

test(`floatingCheckout passes through expirationDate separately from expiresAt (${expirationDate})`, async () => {
await withServer(jsonResponder('/key/checkout', {
code: 0,
message: 'Session checked out',
sessionId: 'session_1234567890123',
sessionSecret: 'secret',
nextNonce: 'nonce',
expiresAt: '2026-10-26T12:30:00.000Z',
heartbeatInterval: 300,
metadata: {},
expirationDate,
currentSessions: 1,
maxSessions: 5
}), async baseUrl => {
const result = await new KeyMint('client_test', baseUrl).floatingCheckout({
productId: 'product_123', licenseKey: 'license_123', hostId: 'host_123'
});
assert.equal(result.expiresAt, '2026-10-26T12:30:00.000Z');
assert.ok('expirationDate' in result);
assert.equal(result.expirationDate, expirationDate);
});
});
}

test('activateKey tolerates older servers that omit expirationDate', async () => {
await withServer(jsonResponder('/key/activate', { code: 0, message: 'License valid' }), async baseUrl => {
const result = await new KeyMint('client_test', baseUrl).activateKey({
productId: 'product_123', licenseKey: 'license_123'
});
assert.equal(result.expirationDate, undefined);
});
});
Loading