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.
| Action | Allowed source status | Result status |
|---|---|---|
| Revoke | Draft, Active, or Suspended | Revoked |
| Archive | Draft or Revoked | Archived |
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.
| HTTP | Code | Meaning |
|---|---|---|
| 400 | customer_license_actions.invalid_request | Invalid path, paging, header, or strict body |
| 404 | customer_license_actions.not_found | Missing, foreign, or wrong-customer source |
| 409 | customer_license_actions.customer_not_active | Customer is not Active |
| 409 | customer_license_actions.not_issuable | Commercial or policy source is no longer issuable |
| 409 | customer_license_actions.subscription_occupied | Subscription already has a License |
| 409 | customer_license_actions.stale_precondition | Owner evidence changed |
| 409 | customer_license_actions.transition_not_allowed | Terminal transition is not allowed from the current state |
| 409 | customer_license_actions.idempotency_conflict | Key was reused for different content |
| 503 | customer_license_actions.dependency_unavailable | Required owner, audit, or infrastructure unavailable |
| 500 | customer_license_actions.stored_result_invalid | Durable 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.