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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- `product_id` is required: one signing key covers every product in an organization, so a verifier that skips the product check would let a license for one product unlock another.
- New required dependency: `cryptography>=41`, for Ed25519.
- CI now runs every test under `tests/`, including the shared offline-license test vectors.
- Add `delete_key` (`DELETE /key`), which permanently deletes a license key. Use `block_key` to stop a key reversibly.

## 2.6.0 - 2026-10-26

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ Keymint provides utilities to uniquely identify machines for node-locking:
| `update_key` | Updates an existing license key. |
| `block_key` | Blocks a license key. |
| `unblock_key` | Unblocks a previously blocked license key. |
| `delete_key` | Permanently deletes a license key. |
| `sign_key` | Signs a key for offline validation using an admin API key.|
| `floating_checkout` | Checks out a floating license seat. |
| `floating_heartbeat`| Sends a heartbeat to keep a session alive. |
Expand All @@ -176,7 +177,7 @@ Keymint provides utilities to uniquely identify machines for node-locking:
| `get_customer_with_keys`| Gets a customer along with their license keys. |
| `update_customer` | Updates customer information. |
| `toggle_customer_status`| Toggles customer active status. |
| `delete_customer` | Permanently deletes a customer and their keys. |
| `delete_customer` | Deletes a customer. Their license keys are kept; use `delete_key` to remove them. |

### Webhook Verification

Expand Down
20 changes: 17 additions & 3 deletions keymint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ def __init__(self, api_key: str, base_url: str = "https://api.keymint.dev"):
'Content-Type': 'application/json'
}

def _handle_request(self, method: str, endpoint: str, params: dict | None = None, query_params: dict | None = None, idempotency_key: str | None = None, extra_headers: dict | None = None):
def _handle_request(self, method: str, endpoint: str, params: dict | None = None, query_params: dict | None = None, idempotency_key: str | None = None, extra_headers: dict | None = None, delete_body: dict | None = None):
# DELETE sends a JSON body only when delete_body is given (delete_key).
url = f'{self.base_url}{endpoint}'
headers = self.headers.copy()
if idempotency_key:
Expand All @@ -49,7 +50,10 @@ def _handle_request(self, method: str, endpoint: str, params: dict | None = None
elif method.upper() == 'PATCH':
response = requests.patch(url, json=params, params=query_params, headers=headers)
elif method.upper() == 'DELETE':
response = requests.delete(url, params=query_params, headers=headers)
if delete_body is not None:
response = requests.delete(url, json=delete_body, params=query_params, headers=headers)
else:
response = requests.delete(url, params=query_params, headers=headers)
else:
raise ValueError(f"Unsupported HTTP method: {method}")

Expand Down Expand Up @@ -170,6 +174,16 @@ def unblock_key(self, params: UnblockKeyParams, idempotency_key: str | None = No
"""
return self._handle_request('POST', '/key/unblock', params, idempotency_key=idempotency_key)

def delete_key(self, params: DeleteKeyParams, idempotency_key: str | None = None) -> DeleteKeyResponse:
"""
Permanently deletes a license key. Use block_key to stop a key reversibly.
:param params: Parameters for deleting the key.
:param idempotency_key: Optional unique identifier to ensure request idempotency.
:returns: The deletion confirmation.
"""
body = {'productId': params['productId'], 'licenseKey': params['licenseKey']}
return self._handle_request('DELETE', '/key', idempotency_key=idempotency_key, delete_body=body)

def update_key(self, params: 'UpdateKeyParams', idempotency_key: str | None = None) -> 'UpdateKeyResponse':
"""
Updates an existing license key.
Expand Down Expand Up @@ -229,7 +243,7 @@ def update_customer(self, params: UpdateCustomerParams, idempotency_key: str | N

def delete_customer(self, params: DeleteCustomerParams, idempotency_key: str | None = None) -> DeleteCustomerResponse:
"""
Permanently deletes a customer and all associated license keys.
Deletes a customer. Their license keys are kept; use delete_key to remove them.
:param params: Parameters containing the customer ID.
:param idempotency_key: Optional unique identifier to ensure request idempotency.
:returns: The deletion confirmation.
Expand Down
8 changes: 8 additions & 0 deletions keymint/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ class UnblockKeyResponse(TypedDict):
message: str
code: int

class DeleteKeyParams(TypedDict):
productId: str
licenseKey: str

class DeleteKeyResponse(TypedDict):
message: str
code: int

# Customer Management Types

class CreateCustomerParams(TypedDict):
Expand Down
41 changes: 41 additions & 0 deletions tests/test_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,44 @@ def test_floating_checkout_returns_expiration_date_separate_from_lease(mock_post
assert mock_post.call_args.args[0].endswith("/key/checkout")
assert result["expirationDate"] == expiration_date
assert result["expiresAt"] == "2026-10-26T12:05:00.000Z"


@patch("keymint.requests.delete")
def test_delete_key_sends_json_body_not_url(mock_delete):
mock_delete.return_value = response({"message": "Key deleted", "code": 0})

result = KeyMint("admin_test").delete_key({"productId": "product_123", "licenseKey": "secret/license+key"})

args, kwargs = mock_delete.call_args
url = args[0]
assert url == "https://api.keymint.dev/key"
assert kwargs["json"] == {"productId": "product_123", "licenseKey": "secret/license+key"}
assert kwargs["params"] is None
assert "secret" not in url and "license" not in url
assert "x-license-key" not in kwargs["headers"]
assert kwargs["headers"]["Authorization"] == "Bearer admin_test"
assert kwargs["headers"]["Content-Type"] == "application/json"
assert result == {"message": "Key deleted", "code": 0}


@patch("keymint.requests.delete")
def test_delete_key_raises_on_404(mock_delete):
mock_delete.return_value = response({"message": "Invalid license key", "code": 1}, 404)

with pytest.raises(KeyMintApiError, match="Invalid license key") as excinfo:
KeyMint("admin_test").delete_key({"productId": "product_123", "licenseKey": "license_123"})

assert excinfo.value.status == 404
assert excinfo.value.code == 1


@patch("keymint.requests.delete")
def test_delete_customer_still_uses_query_params_and_no_body(mock_delete):
mock_delete.return_value = response({"action": "deleteCustomer", "status": True, "code": 0})

KeyMint("admin_test").delete_customer({"customerId": "cust_123"})

args, kwargs = mock_delete.call_args
assert args[0] == "https://api.keymint.dev/customer/by-id"
assert kwargs["params"] == {"customerId": "cust_123"}
assert set(kwargs) == {"params", "headers"}
Loading