Skip to main content

Customer License actions API

The Management API exposes customer-scoped discovery and durable Subscription issue, revoke, archive, and key-rotation actions for Vendor Console. Every route requires customers.read and licenses.manage; Tenant and actor identity come only from the authenticated user or service-account context.

Discovery and issue

GET /api/v1/customers/organizations/{organizationId}/license-issue-candidates?offset=0&limit=50

Discovery returns only Trialing or Active Subscriptions pinned to an exact Published or Retired OfferVersion, owned by the path Customer, while effectiveIssuable remains positive (min of tariff SeatLimit remaining and shared pool remaining). Missing SeatLimit or seat pool fails closed (not treated as unlimited). The Customer must be Active. Ordering is fixed by Subscription creation time descending and Subscription ID ascending. offset defaults to 0; limit defaults to 50 and accepts 1..200.

The response includes Product and tariff display fields plus immutable Subscription/Offer identity. It never contains policy references, License key material, hashes, provider references, or internal codes. Discovery is advisory: issue re-reads all owners and working-seat remaining.

POST /api/v1/customers/organizations/{organizationId}/license-issues
Idempotency-Key: customer-generated-value
Content-Type: application/json

{
"customerSubscriptionId": "0198f35d-7b00-7000-8000-000000000001",
"correlationId": "0198f35d-7b00-7000-8000-000000000002"
}

Product, Customer, OfferVersion, policy, validity, License code, and plaintext key are not request fields. A first committed issue returns 201 and the existing Subscription issue receipt, including the format-v2 plaintext key. An exact durable replay returns 200, the same references and mask, and plaintextKey: null. Reusing the key for another semantic request returns a conflict. Revoked and Archived Licenses still occupy their source Subscription for life.

Terminal actions

POST /api/v1/customers/organizations/{organizationId}/licenses/{licenseId}/revoke
POST /api/v1/customers/organizations/{organizationId}/licenses/{licenseId}/archive
Content-Type: application/json

{
"expectedStatus": "Active",
"reasonCode": "customer_request",
"reasonDetail": "optional bounded safe detail"
}

The JSON object is strict: its only members are expectedStatus, reasonCode, and optional reasonDetail; unknown or duplicate members are rejected. expectedStatus is case-sensitive and protects against stale UI state. These actions do not use Idempotency-Key and accept no Tenant, Customer, Product, policy, operation, or actor identity in the body.

ActionAllowed source statusResult status
RevokeDraft, Active, or SuspendedRevoked
ArchiveDraft or RevokedArchived

A success returns 200 with operationId, identical effectId, licenseId, action, fromStatus, toStatus, completedAtUtc, and isIdempotentReplay. An exact replay returns the original receipt with isIdempotentReplay: true; a changed or stale attempt returns a conflict. The path Customer must exist and exactly match the License's stored Customer.

The License transition, audit evidence, and integration outbox message commit as one durable unit before the response. Revoke then publishes only best-effort cache hints/invalidation; archive only invalidates cached assists. A cache failure does not undo the committed action, and PostgreSQL remains authoritative.

The existing global single and bulk revoke routes keep their request and response envelopes. Their legacy revokedByActorId body field is deprecated, accepted, and ignored; the authenticated Management actor is always used. Repeated global single revoke remains HTTP 409, while an already-applied bulk item remains a successful item with alreadyApplied: true.

Durable key rotation

POST /api/v1/customers/organizations/{organizationId}/licenses/{licenseId}/key/rotations
Idempotency-Key: customer-generated-value
Content-Type: application/json

{
"expectedPrimaryMaterialRefId": "0198f35d-7b00-7000-8000-000000000001"
}

The body is strict and accepts only the current primary material reference as a stale-write precondition. The header must contain exactly one bounded Idempotency-Key. Tenant, Customer, License, and actor authority never come from the body. A new effect requires the path License to belong to the exact Active Customer; BC-LIC then revalidates License state, migration state, the current primary, and its immutable policy snapshot inside the atomic rotation.

Success returns 200 with operationId, licenseId, keyMaterialRefId, maskedKey, formatVersion, createdAtUtc, plaintextKey, and isIdempotentReplay. The first fully successful call can reveal the new v2 plaintext once. An exact retry returns the durable references and mask with plaintextKey: null and isIdempotentReplay: true; reusing the key for changed content is a conflict.

The BC-LIC operation commits before its idempotent license.key.rotated audit append, but HTTP success is sent only after both are durable. If audit or response delivery fails after the rotation commit, retry the identical header and body: no second key is created, missing audit work is completed, and the replay still returns null plaintext. A status change after the committed effect does not prevent that recovery replay. Every handler response is marked no-store and no-cache; plaintext is never persisted, audited, or logged.

The existing global POST /api/v1/licenses/{licenseId}/key/rotate route keeps its v1 success envelope and requires only licenses.manage. Reliable mode uses the same expected-primary body together with Idempotency-Key. Omitting both enters deprecated headerless compatibility mode: it remains internally atomic but is non-retryable over the network and returns Deprecation plus Warning headers. Supplying only one of the header or body is invalid. New integrations must use reliable mode; the Customer route never falls back to compatibility mode.

Errors

All errors use the platform error body and a correlation ID.

HTTPCodeMeaning
400customer_license_actions.invalid_requestInvalid path, paging, header, or strict body
404customer_license_actions.not_foundMissing, foreign, or wrong-customer source
409customer_license_actions.customer_not_activeCustomer is not Active
409customer_license_actions.not_issuableCommercial or policy source is no longer issuable
409customer_license_actions.subscription_occupiedSubscription already has a License
409customer_license_actions.stale_preconditionOwner evidence changed
409customer_license_actions.transition_not_allowedTerminal transition is not allowed from the current state
409customer_license_actions.idempotency_conflictKey was reused for different content
503customer_license_actions.dependency_unavailableRequired owner, audit, or infrastructure unavailable
500customer_license_actions.stored_result_invalidDurable issue or rotation evidence violates the contract

Errors and replays never reveal plaintext key material. The existing global POST /api/v1/licenses/subscription-issues route remains compatible.