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.

Allocation creates access provenance, not a customer identity.
  1. LicenseEntitlements, validity, and seat capacity
  2. AssignmentOne external user consumes one License seat
  3. GrantsRecord why this Assignment provides each Entitlement

Grants → Effective Entitlements → hasAccess

The projection is optimized for authorization. The current access decision still depends on validity.

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.

OperationSecurityGeneric Idempotency-KeyRecovery / purpose
Machine single assignmentMachineApiKeyRequiredRetain the same request and key after uncertainty.
Developer single assignmentDeveloperBearerNo generic keyRead canonical Assignment state before another deliberate mutation.
Developer bulk assignmentDeveloperBearerRequiredRetain the original user set, License, and key.
Machine exact releaseMachineApiKeyNo generic keyReconcile the exact Assignment ID.
Developer exact releaseDeveloperBearerNo generic keyReconcile the exact Assignment ID.
Portal exact releaseHumanBearerNo generic keyReconcile the exact Assignment ID with current Organization authority.
Developer bulk releaseDeveloperBearerNo generic keyRead current Assignments; never blindly replay against replacement seats.
CSV roster previewDeveloperBearerNo generic keyRead-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.

Machine assignment — your backend retains this logical request's key
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.

Developer bulk assignment — a human control-plane credential, not bm_sk_
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.

Developer roster preview — raw CSV, not JSON or multipart
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_002

The 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 errorAction before assignment
missing_external_user_idSupply the intended stable user ID.
invalid_external_user_id

Fix the value against the contract without silently changing identity.

duplicate_external_user_idResolve 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.

Developer assignment ledger — exact external ID, including revoked history
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

CapacityWhat countsRelease effect
License seat capacity

assignedCount against seatLimit: unreleased seats on one License.

Decrease by one per actual released Assignment.
Workspace organization-user capacity

Distinct (Project, Organization, externalUserId) allocation identities across the Account.

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.

Machine exact release — retain the original assignmentId, no generic request key
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.

Release removes only this Assignment's contribution.
  1. Revoke AssignmentSet revokedAt; keep the historical row
  2. Revoke its GrantsDecrement the License allocation count
  3. Reconcile projectionInspect remaining valid Grants for each affected Entitlement
Another valid Grant existsAccess remains for that Entitlement.
No valid Grant remainsAccess disappears for that 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.

OperationTargetAfter an uncertain result
Individual exact release

Immutable assignmentId

Read that exact Assignment. Repeating old A cannot affect replacement B.

Bulk release

externalUserIds against then-current Assignments

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.

Illustration: both Licenses currently grant analytics.
License A → Assignment AGrant A: analytics
License B → Assignment BGrant B: analytics

Both paths → Effective analytics access

  1. Both seats allocatedTwo valid Grants → hasAccess = true
  2. Release Assignment AGrant A revoked; Grant B valid → hasAccess = true
  3. 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

SymptomCheck / 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.