Seat lifecycle & reconciliation
Manage seat assignments, release and reassignment, capacity, and access reconciliation without confusing allocation with authorization.
A seat Assignment allocates one License to one externalUserId. It is not a customer-user profile, authentication, a login, an email identity, or an Entitlement. Your application retains customer identity.
This guide follows an allocation after creation: how to inspect it, release it, assign a replacement, and reconcile capacity and access. For credential-based onboarding, use Customer provisioning. For definitions, start with Members & seats.
- LicenseEntitlements, validity, and seat capacity
- AssignmentOne external user consumes one License seat
- GrantsRecord why this Assignment provides each Entitlement
Grants → Effective Entitlements → hasAccess
An operationally active Assignment means revokedAt == null: an unreleased allocation. It does not prove the License is valid now. Allocation, Organization membership, Workspace usage, and effective access answer different questions.
1. Choose the correct assignment operation
Use the machine API from your customer backend. Use developer APIs for human control-plane workflows. Organization administrators have supported portal read/release authority; they provision through Invitations and Access Codes, not a direct seat-assignment endpoint. Do not exchange authentication surfaces just to obtain different retry behavior.
The table below reads security schemes and required-key markers from the committed OpenAPI. “No generic key” means the operation does not currently support the durable request-idempotency contract; adding a header does not supply that guarantee.
| Operation | Security | Generic Idempotency-Key | Recovery / purpose |
|---|---|---|---|
| Machine single assignment | MachineApiKey | Required | Retain the same request and key after uncertainty. |
| Developer single assignment | DeveloperBearer | No generic key | Read canonical Assignment state before another deliberate mutation. |
| Developer bulk assignment | DeveloperBearer | Required | Retain the original user set, License, and key. |
| Machine exact release | MachineApiKey | No generic key | Reconcile the exact Assignment ID. |
| Developer exact release | DeveloperBearer | No generic key | Reconcile the exact Assignment ID. |
| Portal exact release | HumanBearer | No generic key | Reconcile the exact Assignment ID with current Organization authority. |
| Developer bulk release | DeveloperBearer | No generic key | Read current Assignments; never blindly replay against replacement seats. |
| CSV roster preview | DeveloperBearer | No generic key | Read-only preview: no allocation or capacity reservation. |
See Authentication and Idempotency & retries. Examples below use the configured HTTPS API base: https://api.businessmode.io/v1. Replace path placeholders and synthetic credentials with your server-side values.
One known user from your backend
Machine single assignment uses MachineApiKey and requires an Idempotency-Key. Keep bm_sk_... server-side, never in customer browser or mobile code.
POST /v1/licenses/{licenseId}/assignments
Host: api.businessmode.io
Authorization: Bearer bm_sk_...
Idempotency-Key: 00000000-0000-4000-8000-000000000001
Content-Type: application/json
{"externalUserId":"customer_user_123"}One logical assignment attempt gets one key. After a lost or ambiguous response, retry the same License, externalUserId, body, and key. Do not generate a replacement key to escape uncertainty.
BusinessMode permits one active Assignment per (Project, License, externalUserId). A new direct request for an already-seated user returns seat_already_assigned; do not try to create duplicate active seats.
Developer single assignment is a different operation: POST /projects/{projectId}/licenses/{licenseId}/assignments, authenticated with DeveloperBearer. It is not currently marked for durable generic Idempotency-Key support. After uncertainty, inspect canonical Assignment state before another deliberate mutation.
Many known users in a developer workflow
Bulk assignment accepts 1..1000 unique externalUserIds, uses DeveloperBearer, and requires a retained Idempotency-Key. This is an operator/developer workflow, not a machine-key endpoint.
POST /v1/projects/{projectId}/licenses/{licenseId}/bulk-assignments
Host: api.businessmode.io
Authorization: Bearer <developer-access-token>
Idempotency-Key: 00000000-0000-4000-8000-000000000002
Content-Type: application/json
{"externalUserIds":["customer_user_001","customer_user_002"]}Users already actively assigned to this License appear in alreadyAssigned; they are not failures and do not receive duplicate seats. Only missing users create new Assignments:
requestedCount: all requested external IDs.assignedCount: new Assignments created by this request.alreadyAssignedCount: requested users who already held active Assignments.
Bulk assignment is one transactional allocation operation. If capacity for the missing users is insufficient, the request fails without partially committing an arbitrary subset of new Assignments. Already-existing seats remain existing state.
What commits with a new allocation
The canonical transaction validates License state, checks current assignments, enforces Workspace organization-user capacity and License seat capacity, creates Assignments and Grants, reconciles Effective Entitlements, increments the License's assignedCount, and writes Audit and Outbox records. Those changes commit atomically.
The final mutation is capacity authority. Reading assignedCount < seatLimit can inform a UI, but is not permission to allocate: another writer can change the state. Handle final conflicts such as seat_already_assigned, seat_limit_exceeded, and account_org_user_limit_exceeded; do not substitute your own preflight counter or application mutex for API enforcement.
2. Preview a CSV roster, then commit deliberately
CSV roster preview accepts a raw text/csv body, not JSON or a multipart upload. It creates no Assignments and requires no mutation key.
POST /v1/projects/{projectId}/licenses/{licenseId}/roster-preview
Host: api.businessmode.io
Authorization: Bearer <developer-access-token>
Content-Type: text/csv
externalUserId
customer_user_001
customer_user_002The input limits are 1,048,576 UTF-8 bytes (1 MiB) and 1..1000 data rows. The header must contain exactly one column named externalUserId. Additional CSV columns may exist.
External user IDs are opaque. The parser does not trim, lowercase, normalize email, or normalize UUID values. Leading or trailing whitespace is invalid. Apply only your application's own canonical identity rule before constructing a request; never silently change IDs during an import.
| Row error | Action before assignment |
|---|---|
missing_external_user_id | Supply the intended stable user ID. |
invalid_external_user_id | Fix the value against the contract without silently changing identity. |
duplicate_external_user_id | Resolve the repeated ID deliberately. |
The preview reports CSV line numbers. Do not discard invalid rows and call the remainder an approved roster.
The response separates alreadyAssigned from wouldAssignExternalUserIds and reports currentAssignedCount, seatLimit, availableSeatCount, and wouldAssignCount. Inspect licenseAssignable and blockingReason as well as capacitySufficient; invalid rows or an inactive License still block assignment.
Preview checks License seat capacity. It does not check Workspace organization-user capacity. A true capacitySufficient means License capacity looked sufficient at preview time, not that the final request will succeed.
Preview is not a reservation. It does not reserve License seats, Workspace capacity, or License lifecycle state. Concurrent allocations, a changed License, or Workspace quota can invalidate a favorable preview. The final bulk transaction remains authoritative.
The endpoint is assignment-oriented, not a release preview. Its alreadyAssigned, wouldAssign, capacity, and assignability fields do not promise which users a later bulk release will release. A release tool may reuse CSV validation carefully, but must inspect current Assignment state.
Recommended operator flow: CSV → validate all rows → review already assigned / would assign → bulk assign with a retained key → refresh canonical seat and Member state.
3. Read Members and the Assignment ledger separately
An Organization Member is an external user with at least one unreleased Assignment across Licenses in that Organization. It is an aggregate read model, not a customer-user table.
The row contains externalUserId, activeSeatCount, firstAssignedAt, and lastAssignedAt. activeSeatCount counts unreleased Assignments, where revokedAt == null; the field does not assert that every License is currently valid.
firstAssignedAt and lastAssignedAt describe the current unreleased Assignment set, not lifetime membership history. Released historical rows are excluded from those minimum/maximum dates.
If Alice has seats on License A and License B in Organization O, her activeSeatCount is 2. Release A: the count becomes 1 and she remains a Member. Release B, with no other unreleased seat in O: she disappears from the aggregate Member list. Her identity and historical records are not deleted.
The License Assignment ledger instead returns one row per Assignment, with active, revoked, or all filtering and an exact externalUserId filter. Follow its pagination for a complete ledger.
GET /v1/projects/{projectId}/licenses/{licenseId}/assignments?status=all&externalUserId=customer_user_123
Host: api.businessmode.io
Authorization: Bearer <developer-access-token>Use the aggregate endpoint for Organization membership rather than one Assignment request per Member. Use get exact Assignment and the ledger for lifecycle/history questions. The portal Assignment list serves the authorized Organization-admin surface.
4. Reconcile capacity and billing usage
| Capacity | What counts | Release effect |
|---|---|---|
| License seat capacity |
| Decrease by one per actual released Assignment. |
| Workspace organization-user capacity | Distinct | Decrease only when the final unreleased seat for that Organization user is removed. |
| Access Code lifetime redemptions | Successful unique-user uses of one credential, not an allocation pool. | Seat release does not replenish credential-use capacity. |
Alice holding two Licenses in the same Organization consumes one seat on each License, but one Workspace organization-user unit. The same external ID in two Organizations contributes two units because Organization is part of the usage identity.
Adding another License seat for an already-allocated Organization user creates no additional Workspace unit. It can be allowed even when the Workspace is at or over its distinct-user limit, provided the License is active and has seat capacity. Being over limit does not mean all seat changes are blocked.
Release one of Alice's two seats in the same Organization: Workspace usage is unchanged. Release her final unreleased seat: usage decreases by one. Natural License expiration can leave these allocations counted until they are released or revoked.
Refresh canonical Workspace Billing after changes; do not infer usage by summing License counts or successful request counts. This is allocation-based usage reconciliation, not a promise about invoice adjustments. See Pricing for plan capacity and Customer provisioning for Access Code limits.
The License's assignedCount is current allocation, not lifetime use: +1 for a new Assignment, -1 per actual release, and 0 after explicit License revocation.
5. Release the intended allocation
Individual release targets an immutable Assignment ID
Machine exact release, developer exact release, and portal exact release all target assignmentId. Current scope authorization still applies.
DELETE /v1/licenses/{licenseId}/assignments/{assignmentId}
Host: api.businessmode.io
Authorization: Bearer bm_sk_...Release does not require the License to be active. An unreleased allocation on a naturally expired License can still be released.
- Revoke AssignmentSet revokedAt; keep the historical row
- Revoke its GrantsDecrement the License allocation count
- Reconcile projectionInspect remaining valid Grants for each affected Entitlement
For an actual release, the transaction sets the Assignment's revokedAt, revokes its active Grants, reconciles affected Effective Entitlements, decrements the License count, and writes seat.released Audit and Outbox records.
Releasing an already-revoked exact Assignment is a successful no-op: no second decrement and no duplicate seat.released Audit or Outbox event. The historical Assignment row remains.
Individual release has no generic Idempotency-Key. After transport ambiguity, read the exact Assignment first. If revoked, the result is resolved. If still unreleased, decide whether to send another deliberate release of that same ID.
After release, the same external user may receive the same License again. This creates a new Assignment with a new assignmentId. Repeating DELETE for old Assignment A cannot release replacement Assignment B. Do not replace the old target ID with a newer one during recovery.
Bulk release targets users against current state
Developer bulk release takes externalUserIds, not Assignment IDs, and has no generic Idempotency-Key. It releases the currently active matching Assignments in one transaction, with canonical Grant, projection, License count, Audit, and Outbox updates for every actual release.
The response returns requestedCount, releasedCount, released, notActiveCount, and notActiveExternalUserIds. A user with no current active Assignment is a normal no-op, not a failed row.
Never blindly replay an ambiguous bulk release. Suppose the release committed but its response was lost. Alice later receives replacement Assignment B. Repeating the old bulk request can release B because it selects Alice's currently active Assignment at execution time.
Read canonical Assignment state first, including history where needed, and make a new deliberate decision. See Errors & recovery; generic “retry every POST” middleware is not a safe lifecycle policy.
| Operation | Target | After an uncertain result |
|---|---|---|
| Individual exact release | Immutable | Read that exact Assignment. Repeating old A cannot affect replacement B. |
| Bulk release |
| Read current Assignment state. A blind delayed replay can release a replacement seat. |
6. Distinguish natural expiry from explicit revocation
Natural License expiration ends validity, not allocation
When validUntil passes, BusinessMode does not automatically revoke Assignment rows merely because time passed. An Assignment can remain unreleased, a Member can remain, and Workspace billing usage can remain while hasAccess is already false.
License-derived Grant validity ends with the License. The access read checks Effective Entitlement validity against current time, so time-based access expiry does not require a release mutation. Another valid Grant may still preserve that Entitlement.
Natural expiration does not emit seat.released. Do not wait for a seat-release Webhook to learn that time-limited access has ended. Use License validity and current access semantics.
To renew retained seats, use Extend license in Product or POST /v1/projects/:projectId/licenses/:licenseId/extend with { "validUntil": "2030-01-01T00:00:00Z" } and an Idempotency-Key. This is an absolute "at least this date" command. Equal or earlier targets are successful no-ops; forward targets must be after the unchanged start date and in the future at transaction time. Refresh canonical License state after success or replay.
Extension preserves License, Assignment and eligible Grant IDs. It amends eligible non-revoked Grant expirations for unreleased seats and can recreate a missing Effective Entitlement row from currently valid canonical Grants. Naturally expired seats regain applicable access only after commit, with no claim of access during the lapse. Released users remain released. Revoked Licenses cannot be revived. A scheduled License retains its start date; a non-expiring License needs no extension.
No allocation counts change. Workspace Billing capacity checks still apply to subsequent assignments. Invitations, Access Codes, their expirations, revocations and redemption histories remain unchanged; no email is automatically sent. A still-valid pending credential may become redeemable when its License becomes active again, subject to its own rules. Extension does not collect a payment.
Explicit License revocation removes allocations
Revoke License transactionally revokes active Assignments and active Grants on the License, reconciles affected Effective Entitlements, sets assignedCount to 0, and marks the License revoked.
It emits license.revoked Audit and Outbox records with aggregate revocation context, not one seat.released event per Assignment. Other Licenses can still provide access through their valid Grants.
Revocation is irreversible in the current API. There is no unrevoke/reactivate endpoint; it is not a pause. Read the License concept before choosing revocation instead of releasing selected allocations.
7. Reconcile remaining Grants, then check access
A seat can grant several Entitlements. Releasing it removes only Grants originating from that Assignment. For each affected Entitlement, BusinessMode reconciles the remaining valid Grants into the Effective Entitlement projection.
Both paths → Effective analytics access
- Both seats allocatedTwo valid Grants → hasAccess = true
- Release Assignment AGrant A revoked; Grant B valid → hasAccess = true
- Release Assignment BNo valid analytics Grant remains → hasAccess = false
Releasing one seat can leave hasAccess true because another valid Grant remains. Removing the final valid Grant makes hasAccess false for that Entitlement. Neither result requires deleting the customer's identity.
Grants preserve access provenance and are the source of truth; Effective Entitlement is the optimized authorization projection. Integrators do not mutate that projection directly.
Always use hasAccess, GET /access with externalUserId and entitlementKey, for the runtime decision. Assignment history, Member rows, billing usage, and provisioning receipts are not authorization. See Access decisions.
8. Update a previously used License carefully
License updates allow a developer to change supported fields on a non-revoked License, subject to lifecycle constraints.
A new seatLimit cannot be below current assignedCount; the API returns seat_limit_below_assigned_count. Release the appropriate allocations first if the desired capacity is lower than usage.
Ordinary PATCH has a configuration rule: any historical Assignment, current or revoked, locks changes to the Entitlement set, validFrom, and validUntil. The lock is based on historical hasAnyAssignment state, not the current count. Use the explicit extend License operation to move expiration forward while retaining allocations. Start date and Entitlements stay locked.
Releasing every seat and returning assignedCount to 0 does not unlock that configuration. A License that once had ten seats still has Assignment history after all ten are released. license_configuration_locked is expected.
This does not make the entire License immutable. On a non-revoked License, supported name and seatLimit updates remain available within their constraints. Omit the locked configuration fields from such a PATCH; including them is itself subject to the historical-use check.
Renewal uses the same durable License identity and does not require a replacement License or user migration. Extension amends current eligible Grant expiration with Audit provenance; current Grant rows do not provide historical access-at-time queries or a legal agreement ledger.
9. Keep Audit and Webhook effects distinct
Actual individual or bulk assignment writes seat.assigned; actual individual or bulk release writes seat.released. Each uses the Assignment resource identity and relevant metadata. Already-assigned bulk entries and already-released exact no-ops do not produce duplicate mutation history.
Audit retains actor provenance:
api_key: machine backend assignment or release.developer: developer control-plane seat management.organization_admin: authorized portal release.
Actual seat changes also write the corresponding Outbox event for Webhook fanout. Delivery and receiver retry behavior are separate from the allocation transaction.
Explicit License revocation emits aggregate license.revoked, not a burst of per-seat release events. Natural validity expiration promises no synthetic seat.released event at all. Consumers should not reconstruct current authorization solely from seat event history.
An actual extension emits one license.extended Audit event and one Outbox event (version 1), with the previous expiration, target expiration and amendment time. No-op targets and completed idempotent replays emit none. Extension does not emit synthetic seat or invitation events. Direct Project membership is rechecked before replay; a historical replay after later revocation never restores access.
10. Apply the lifecycle to common scenarios
The access results below assume the named Licenses are currently valid, grant the relevant Entitlement, and there are no other access paths unless stated.
A. One License, one seat
Assign → Member appears → hasAccess = true. Release the only Assignment → Member disappears → no valid Grant remains → hasAccess = false. Customer identity and revoked Assignment history remain.
B. Two Licenses grant the same Entitlement
Two seats in one Organization → one Member with activeSeatCount = 2 → one Workspace organization-user unit. Release one → Member remains and hasAccess remains true through the other Grant. Release the final seat/Grant → Member disappears, usage decreases, and access ends.
C. License expires naturally
validUntil passes → seat remains unreleased → Member and billing usage may remain → hasAccess becomes false if no other valid Grant exists. Explicit release is an allocation cleanup decision, not a prerequisite for time-based access expiry.
D. Same user is reassigned later
Assignment A is released → new Assignment B is created for the same user/License → repeating old A's DELETE is a no-op. B remains active; the immutable target prevents the old request from releasing the replacement.
E. Bulk-release response is lost
The first bulk release commits → response is lost → a user is reassigned → a blind repeat could release the new active seat. Re-read current and historical Assignment state, then decide deliberately whether another release is intended.
11. Troubleshoot canonical state
| Symptom | Check / recovery |
|---|---|
seat_already_assigned | The user already has an unreleased Assignment on this License. Read it; do not create a duplicate. |
seat_limit_exceeded | New allocations exceed this License's capacity. Change intended allocation/capacity state before trying again. |
account_org_user_limit_exceeded | The request adds distinct Organization users beyond Workspace capacity. This is separate from License seats. |
license_inactive | New assignment needs a currently assignable License. Check validity and revocation; release has different eligibility. |
| Member remains after one release | Another unreleased seat in the Organization can keep the Member row present. |
| Member remains after License expiry | Aggregation tracks unreleased allocations, not current valid access. |
| Access remains after release | Another valid Grant may provide the Entitlement. Inspect other License allocations and use hasAccess. |
| Count is zero but configuration is locked | Any historical Assignment locks Entitlements/validity, even after every seat is released. |
| Preview passed but assignment failed | Concurrent seat changes, Workspace quota, or changed License state can block the final mutation. |
Do not diagnose these states as stale caches without checking canonical data. Follow Errors & recovery for operation-specific recovery and retain the same logical request/key for marked idempotent mutations.
Preview is guidance. Final transactional mutation is capacity authority. Use hasAccess for the access decision.