Exchange is excluded from this API/CLI release. Any historical Exchange API or CLI example in this guide is unavailable; native portal behavior is outside this exclusion. See [current release scope](/developers/guides/release-scope?lang=en).

# Managing companies and users

Portal login is passkey-only. `set-password` and `send-password-reset-email` are retired. Use `send-setup-email` only for first-passkey enrollment of an eligible identity; an email link never bypasses an existing passkey. See [setup and recovery](/developers/guides/passkeys?lang=en).

These administration routes use a personal OAuth connection with `admin:read` or `admin:write`, a current internal administrator role and passkey verification no more than five minutes old. API, write, sensitive-action and administration flags and configured release policy remain effective. The account in `/api/v1/accounts/{account}` is your own current administration account on which the connection was approved. A target company or user has a separate explicit ID.

| Operation | Method and path after `/api/v1/accounts/{account}` |
| --- | --- |
| Search companies | `GET /admin/companies?search=...&cursor=...&limit=50` |
| Read company and version | `GET /admin/companies/{company}` |
| Create a company with a new owner | `POST /admin/companies` |
| Update company details, ordering and security policy | `PATCH /admin/companies/{company}` |
| Archive an empty, settled company | `DELETE /admin/companies/{company}` |
| Search users | `GET /admin/users?search=...&company_id=...&cursor=...&limit=50` |
| Read user and version | `GET /admin/users/{user}` |
| Review only changed original blocked memberships | `GET /admin/users/{user}/unblock-reviews?cursor=...&limit=50` |
| Read memberships with permissions and individual versions | `GET /admin/users/{user}/memberships?cursor=...&limit=50` |
| Update personal details or internal administrator role | `PATCH /admin/users/{user}` |
| Block or unblock an identity | `POST /admin/users/{user}/actions/block` and `/unblock` |
| Submit welcome or recovery mail | `POST /admin/users/{user}/actions/send-setup-email` |
| Investigate bounded delivery statuses | `GET /admin/users/{user}/mail-deliveries?limit=50&cursor=...` |
| Archive a user | `DELETE /admin/users/{user}` |

All lists use bounded cursors, with at most 100 results per page. A user projection contains at most 100 memberships, plus `membership_count` and `memberships_next_cursor`. The separate memberships path provides subsequent pages and explicit permissions for a recovery review.

## CLI for people and AI agents

Use `binadit admin companies` and `binadit admin users`. The former `admin accounts` is an alias for `admin companies`; it uses the same real `/admin/companies` contract. `--account` remains your own approved administration company, while the argument to `get`, `update` or an action is the separate target ID. Sign in with explicit `admin:read admin:write` scopes and verify the session with a passkey; expired strong evidence requires reconnecting.

```sh
binadit admin companies list --search 'Voorbeeld' --account 1 --limit 50
binadit admin companies get 42 --account 1
binadit admin companies update 42 --account 1 --version '<ETag>' --from-file company.json --dry-run
binadit admin companies update 42 --account 1 --version '<ETag>' --from-file company.json
binadit admin companies create --account 1 --from-file new-company.json
binadit admin users list --company 42 --account 1 --limit 50
binadit admin users memberships 8 --account 1 --limit 50
binadit admin users unblock-reviews 8 --account 1 --limit 50 --all
binadit admin users block 8 --account 1 --version '<ETag>' --confirm-user 8 --dry-run
binadit admin users unblock 8 --account 1 --version '<ETag>' --confirm-user 8 --from-file reviewed-memberships.json
binadit admin companies archive 42 --account 1 --version '<ETag>' --confirm-company 42
```

`company.json` contains company fields only. Keep personal-data files private (0600 on Unix). `--from-file -` reads JSON from stdin. Portal passwords are no longer set; use a passkey setup link.

Use `--cursor` for a next page, or deliberately use `--all` for a bounded collection. An AI agent must use versions and exact target IDs from the resources read and substantively review changed membership permissions. `--yes` replaces only local interactive business confirmation for intentional automation; it grants no additional server rights. Investigate an uncertain mutation using its original intent key, rather than repeating it with an automatically chosen new key.

## Safe changes

Read the target and send the current `ETag` as `If-Match`. For an actual API mutation, also send your own `Idempotency-Key`. `dry_run:true` or `X-Binadit-Dry-Run:true` applies the same versions, current permissions and business rules without saving the change. Expected preflight checks consume no mutation intent. A confirmed change with the same key returns the original result. On an unknown failure, inspect the existing operation first.

Blocking and unblocking require `confirm_user_id`. Archiving requires `confirm_user_id` or `confirm_company_id` with the exact target ID. A missing version gives 428; a changed version gives 412. After a change, read the new version from `data.version`.

A new company receives a new, unverified owner. Creation returns that owner's ID and `setup_email_required:true`. Accounting registration and recovery/welcome mail are separate administration steps; this creation produces no supplier or mail effect. A user can change their login address through mailbox confirmation at `/account/identity/email`. The company contact address is an independent company field.





Bank details do not appear in the general company projection. A bank change uses the same IBAN validation and existing-mandate check as the company owner. An existing SEPA or Mollie mandate requires a separately reviewed migration.

## Team rights (members of a company)

A company membership is `owner` or `member`. An owner holds every right below implicitly. A member holds only the rights an owner (or a member with Team write) granted, one row per right in `account_user_permissions` with level `read` or `write`; write includes read. **No right** means the part is not shown at all (page 403, API scope refused, menu entry hidden). **Read** means view only: every change is refused by the server, not only hidden. **Write** means view and change. The catalogue lives in `config/portal.php` (`team_permissions`) and `TeamPolicy::assignableModules()`; the team screen groups it as Services and Account and has an "Administrator" shortcut that fills in the highest level you may grant everywhere except the right to order, saved as ordinary rows.

| Right | Read | Write | Always owner-only |
| --- | --- | --- | --- |
| Services: `module_dns`, `module_domains`, `module_holders`, `module_hosting`, `module_workspace`, `module_exchange`, `module_spam` | View the service | Change; ordering also needs `purchasing` | Credentials (mailbox/Exchange passwords, transfer codes) need write **and** the verified sign-in, with an audit entry |
| `account_team` | Members and invitations | Invite, change, block, remove members and resend/revoke invitations, never beyond your own rights | Company security policy, passkey reset, owner invitations |
| `account_billing` | Invoices, transactions, orders, renewal forecast, products and prices | Pay invoices (sets up the payment mandate) | — |
| `account_company` | Company data | Change company data | Bank details (strong owner verification) |
| `account_api` | Integrations list and metadata, no secrets or client IDs | Create, rotate, revoke, change allowed IPs | — |
| `account_webhooks` | Endpoints with callback addresses reduced to scheme and host | Create, change, rotate, delete, reveal the signing secret | — |
| `purchasing` ("Order and incur costs") | — (one level) | Place orders and make changes that cost money, on top of write on the service itself | Granting it |

Granting rules (`TeamPolicy::delegate`): nobody grants more than they hold; only an owner grants **write** on `account_team`, `account_billing`, `account_api` or `account_webhooks`, and only an owner grants `purchasing` at all (it has one level: `write`; `read` is refused with 422); a member with Team write cannot edit a member who holds a right they lack. Every member keeps their own personal data, security settings and their own CLI sessions.

An integration a member creates is stored with `created_by_role = member`. It works only while that member is still an active member with `account_api` write, and each call also needs that member's current right for the scope used (`ApiIntegration::isUsable`, `ApiPrincipal::authorize`). Owner-created integrations (and all integrations that existed before 11 Nov 2026) keep the owner rule.

### Scope to right

| Scopes | Right | Level |
| --- | --- | --- |
| `account:read` / `account:write` | `account_company` | read / write |
| `billing:read`, `billing.invoices:read`, `products:read` | `account_billing` | read |
| `billing.invoices:pay` | `account_billing` | write |
| `team:read` / `team:write` | `account_team` | read / write |
| `webhooks:read` / `webhooks:write`, `webhooks.secret:read` | `account_webhooks` | read / write, write |
| `dns.*`, `domains*`, `holders:*`, `hosting*`, `workspace*`, `exchange*`, `spam*` | the service module | as listed in `config/api.php`; `*.credentials:*` and `spam.panel:open` need write |
| `orders:read` / `orders:write`, `orders:commit` | `purchasing` | write (one level) |
| `account.bank:*` | owner only (`ApiScopes::ownerOnly`) | — |
| `audit:read` | `module_log` (not assignable, so owners) | read |
| `identity:*`, `dashboard:read`, `operations:read` (only the principal's own operations) | any active membership | — |

The dashboard (web and `GET dashboard`) shows the zone count only with DNS read, the registration count and distribution only with Domains read, the team count only with Team read and the activity only with `audit:read`; the JSON keeps its shape (0, empty list, null).

### The right to order (`purchasing`)

Owner decision (11 Nov 2026): spending money is a separate, deliberate right next to write. Every action that creates a cost or a recurring charge needs **write on the service and `purchasing`**; owners hold it implicitly and internal administrators pass (also while working as a customer). Nobody received it automatically when it was introduced, and the team screen's "Administrator" shortcut never fills it in.

Needs `purchasing` (server-enforced; the portal hides the buttons or shows them disabled with "You need the right to order" where hiding would confuse, such as a basket that already holds items):

- Web: the basket checkout (`POST /cart/checkout`, `BasketOrderPolicy` checks every item), domain registration and transfer (`module_domains/register`, `cart/add`, `migrate`, `checkout`), new hosting (`module_hosting/new`, `create`), new spam filters (`module_spam/order`, `create`), new Exchange mailboxes (`module_exchange/order`, `create`), Workspace: a new mailbox, the Workspace basket (`cart`, `cart/checkout`), a shared mailbox, a mailbox package change and Drive per mailbox (these run through the signed `mailbox-checkout` approval), ordering or enlarging the Drive pool, team drive or receive drive, and Conserio retention longer than now. The legacy order endpoints go through the same `BasketOrderPolicy`.
- API: `orders:read`, `orders:write` and `orders:commit` map to `purchasing`, so quotes, commits, company baskets (`order-batches`), paid shared mailboxes, spam purchases and the unpaid-order withdrawal follow it. `orders:*` is **no longer owner-only**: every API order path also authorizes write on each item's own module (`ApiQuoteService::authorize`, the quote drivers, `ApiBatchQuoteService::approveItem`), so a member integration needs `account_api` write, `purchasing` and the item's module right at every call.

Does not need it: free changes (aliases, settings, DNS, contacts), cancelling, switching off and making Drive or retention smaller, and undoing a pending cancellation within the paid period (resume restores the existing subscription, it orders nothing new). Paying invoices stays `account_billing` write; the company order list (web and `GET orders`) is billing data (`account_billing` read). Known deviation: a smaller mailbox package also needs `purchasing`, because every web package change runs through the same signed checkout approval.

Members with Workspace write may change the DNS records of their workspace's own domains through the Workspace page (MX switch, DNS repair, DNS automation) without a DNS right. This is a deliberate owner decision (10 Nov 2026).

Migration `2026_11_11_060000` converted any legacy `module_account` row into all five account rights at the same level (none existed on production) and gave every active member at that moment `account_billing` read once, so nobody lost invoice visibility. New members get only what is granted explicitly.

## Blocking and restoring access

An administrative block is a durable identity block. It remains effective when a new membership is added later and prevents a new login or OAuth consent. The last active owner, an internal administrator and your own identity have separate protections against blocking or archiving.

Unblocking restores only memberships actually changed by this block. Previously individually disabled or expired access is not unintentionally activated. Deleted memberships remain deleted. If permissions changed during the block, the default action gives 409. Read and review current permissions through the memberships path. A deliberate confirmation uses, for example:

```json
{
  "confirm_user_id": 42,
  "reviewed_memberships": [
    {"id": 73, "version": "<actuele 64-tekensversie uit de lidmaatschappenlijst>"}
  ]
}
```

Only memberships actually affected by this block may be reviewed this way. The user's `If-Match` also remains required.

## Archiving and customer numbers

A company can be archived after its active resources have been explicitly transferred or removed and financial settlement is established. Open invoices, uncertain payment/accounting attempts, refunds, subscriptions, monthly lines, old orders and identifiable related queue work block the action. A terminated monthly line is settled only after the entire paid period has been invoiced; open adjustments and a still-claimed billing producer also block it. Incorrectly linked, still-live Workspace agreements are also checked by their typed subject. Historical resources and financial data are retained; users and passkeys are not deleted as a side effect. Access to their other companies remains independently determined by current memberships.

Migration `410000` installs durable blocking and the allocator for legacy customer identities. All new native/API registrations reserve an unused number above existing, archived, orphaned and reserved legacy owners under the same database lock. The narrowest actually installed integer column bounds the range. A modern user ID never proves legacy ownership. The allocator and earlier reservations survive rollback.

## Concurrent changes and regression evidence

Administrator role changes use a separate platform SQL lock before an ordinary transaction read. Two administrators therefore cannot concurrently remove each other as the last usable administrator. Blocked, archived or unverified identities do not count as replacement administrators or owners.

Inside an existing SQL transaction, shared API permissions and financial effect checks use current locking reads. An older MariaDB `REPEATABLE READ` snapshot or earlier request cache therefore cannot keep approving a revoked grant, token, membership, module permission, administrator role or payment. Original token and resource ceilings remain effective. These transactions are not automatically retried around supplier HTTP.

`tests/Integration/ApiCurrentPolicyReadTest.php` is an explicitly run MariaDB regression suite. It refuses every database except its own `binadit_policy_trial_<12 hex>` with a restricted user and Unix socket. Use an empty temporary instance without TCP access and remove it after the trial. Tests use two separate connections and, for mutex waiting paths, a separate PHP process. They prove revocation after an old snapshot, retention of current permission until commit, financial revocation, mutual role demotion and allocation after waiting. The normal HTTP suite is `tests/Feature/ApiCompanyUserAdminTest.php` and uses real OAuth tokens.

The company/user foundation and the separate recovery-mail workflow below each have their own tested contract. Accounting, support delegation and other administration workflows are added only with their own contracts; their presence cannot be inferred from these routes.

## Restoring email verification

The old administrative field for directly marking someone verified has been replaced by mailbox evidence. `email_verified` is read-only: the API refuses it as a profile change. The existing welcome link is also available for an existing unverified user. API, Rust CLI and portal use the same recovery-mail workflow.

Read the user and current version. Then deliberately submit `send-setup-email` as below. A confirmed sending attempt still leaves `email_verified` at `N`. Only the recipient can create a first passkey through the single-use link in the current mailbox; this action verifies the address and revokes old personal sessions. Existing passkeys and MFA requirements remain effective. A used or expired link cannot work again. A changed login address or identity generation also invalidates the old link.

Afterward, check the outcome with `GET admin/users/{user}` or `binadit admin users get USER --account BEHEERCONTEXT`. Delivery status `accepted` does not prove that the user has opened the link or verified the address. Change the login address only through personal mailbox confirmation, separately from company contact details.

For AI agents: never force `email_verified`, request a recipient override or retrieve the user's mailed token. Initiate the existing reviewed setup-mail action and let the actual recipient complete the passkey and mailbox proof. Poll the ordinary user read route for verification; mail acceptance alone does not establish identity ownership. This is the documented secure replacement for the former administrative verification checkbox.

## Welcome and recovery mail: one deliberate attempt

A mail action uses `admin:write`, the current internal administrator role, recent passkey verification, `If-Match`, `Idempotency-Key` and `confirm_user_id`. The recipient is exclusively the current login address of the explicitly selected user. Input with another address, subject, content or custom link is refused. Recovery mail requires a verified identity; use welcome mail for a new user who is not yet verified. A blocked identity must first be separately reviewed and unblocked.

```sh
binadit admin users get 8 --account 1
binadit admin users send-setup-email 8 --account 1 --version '<ETag>' --confirm-user 8 --dry-run
binadit admin users send-setup-email 8 --account 1 --version '<ETag>' --confirm-user 8
binadit admin users mail-deliveries 8 --account 1 --limit 50
```

All administrators and administration companies share one five-minute cooldown per recipient for setup mail. `dry_run` performs the same current checks without creating a recovery proof, operation, delivery attempt or mail. An actual request records the hash of the current identity-bound recovery proof, delivery attempt and operation reference before SMTP starts. The database holds the current actor and recipient locked during exactly one transport call. There is no automatic SMTP retry or failover. A single SMTP transport requires TLS and certificate verification; an existing local sendmail configuration is also permitted. A log, failover or round-robin mailer is unsuitable for these secret links and produces a controlled 503 before issuance. The array transport exception exists only with a mail fake in tests.

The response and history contain only safe status metadata and an opaque `mail_delivery_id`. API, CLI and browser display no recovery/welcome link, token, mail body or raw SMTP error. The link uses canonical `APP_URL`, never the incoming Host header. `accepted` means that the transport call was confirmed; it does not prove that the recipient has seen the email. Sending does not change original passwords or passkeys. The existing single-use link consumer later executes the shared first-passkey and session policy.

| Status | Meaning and next step |
| --- | --- |
| `prepared` | Proof and attempt are durably stored; no confirmed submission yet. Investigate the original attempt. |
| `dispatching` | A transport attempt may be in flight or interrupted. No automatic retry. |
| `accepted` | The transport call confirmed acceptance. Metadata contains no secret link. |
| `uncertain` | Mail may have been accepted but the response was uncertain. Operation polling shows `requires_reconciliation`. |
| `blocked` | Current proof, actor or recipient changed before transport. No mail was submitted. |

Repeating the same intent key never sends a second email, even after a failure. On `identity_mail_unconfirmed`, first investigate the original operation and delivery history; do not automatically change the key. To deliberately replace an unresolved attempt, read the current user ETag and **latest** delivery attempt, wait out the cooldown and explicitly confirm that attempt:

```sh
binadit admin users send-setup-email 8 --account 1 --version '<nieuw ETag>' --confirm-user 8 --replace-delivery '<nieuwste delivery UUID>'
```

JSON input uses `replace_delivery_id` for this. The new request has its own explicitly reviewed intent. It invalidates both old proof types before submitting one new email and links the earlier attempt through `superseded_by`. An old email delivered later therefore cannot enroll a new passkey. Without explicit review, an unresolved earlier attempt is refused with 409; switching administrator or mail purpose cannot bypass this.

The native user page uses the same service and shows the same safe status. A separate JSON refresh of the edit page returns `mail_version`, without automatically refreshing profile `version`. Unsaved profile fields must never be written over an external profile change with a version refreshed through metadata.

Migration `411000` stores only delivery journals and does not remove them on rollback. `ApiAdminIdentityMailTest` uses real OAuth and native requests for previews, versions, replay, transport errors, replacement and hidden data. `CredentialMailTransportTest` tests maintained SMTP settings and a real synthetic loopback server without STARTTLS: it receives no AUTH or MAIL command. The explicit MariaDB suite `AdminIdentityMailDurabilityTest` requires the same restricted socket database as policy trials, proves the independently committed attempt before SMTP and blocks concurrent actor/recipient changes. No real suppliers or customer mailboxes are contacted.

## A reviewed selection in one local change

`POST /admin/users/actions/bulk` and `POST /admin/companies/actions/bulk` execute the same existing user and company rules for **1 through 100** explicitly read targets. They require `admin:write`, the current internal administrator role, recent passkey verification and one `Idempotency-Key`. Every `items` row carries its own modern `id` and original unquoted 64-character `version` from the resource response read. Use `action: block`, `unblock` or `archive`:

```json
{
  "action": "block",
  "items": [
    {"id": 8, "version": "<64-tekensversie van gebruiker 8>"},
    {"id": 9, "version": "<64-tekensversie van gebruiker 9>"}
  ]
}
```

```sh
binadit admin users bulk --account 1 --from-file reviewed-users.json --dry-run
binadit admin users bulk --account 1 --from-file reviewed-users.json
binadit admin companies bulk --account 1 --from-file reviewed-companies.json
```

The input file follows the same protected-file/stdin rules as other administrative input. The CLI does not replace supplied versions with silently fetched current versions. `reviewed_memberships` is allowed only within a user row for `unblock`; `ban_reason` only within a company row for `block`. Existing individual membership review remains effective. A combination that would block or archive all remaining usable owners is refused even in preview, even if each user individually still has another selected owner.

All targets are reviewed under the same local SQL transaction and current authorization check. One invalid version, administrator/own identity, missing owner, open financial agreement or other unsafe row refuses the entire selection. There is no partial success or silently skipped administrator. An unexpected local write failure rolls back all target changes; a confirmed outcome and its operation are recorded together. This workflow sends no mail and makes no supplier or payment call.

Native blocking, unblocking, archiving and bulk-selection buttons use these shared rules. A browser submits explicit actions and read target versions alongside recent passkey verification. Archiving deletes no passkeys, historical memberships, financial data or other users and moves no resources to an arbitrary administration company. Transfer resources through their separately reviewed ownership workflow and settle financial agreements before archiving a company.

The separate `unblock-reviews` collection contains only still-existing memberships changed since the original administrative block. Each row shows the actual company, current role, status, permissions and version. An owner has all company permissions, even with an empty override list. Missing original memberships and later independently added memberships are not offered as recovery candidates. A page may contain no changed rows yet still have a `next_cursor`: continue until the cursor is empty before reviewing. The native modal performs the same validated bounded pagination and requests explicit confirmation of all changed permissions. A metadata refresh replaces no previously read profile or target version. An oversized review requires a smaller selection, never implicit approval of hidden rows.

Role, status and permission projections are read under current shared SQL locks during review. Inside a transaction, the membership ETag also reads the current complete permission rows; an older MariaDB snapshot cannot approve an outdated permission version. The actual recovery action checks those versions again. The native blocking path accepts only `block` or `unblock`; archiving uses its own explicit deletion/archive route.

## Native company creation with one welcome attempt

The creation dialog for internal administrators first creates the local company, new owner and durable receipt under the same database lock. It requires recent passkey verification and freezes reviewed input, administration company and a new `creation_intent_id`. By default, one welcome attempt follows local commit through the 411 workflow above. Choosing not to send welcome mail also creates no setup proof. Company creation creates no WeFact debtor or payment.

After a lost HTTP response, original input remains frozen. The explicit button for checking the original result uses exactly the same intent and cannot start a second company or welcome attempt. During an in-flight delivery, the same receipt may already return `dispatching`. Local creation remains confirmed even after an uncertain SMTP outcome; `email_sent: false` and `requires_reconciliation: true` require investigating the new owner's mail history. A new or replacement email follows only the separate explicitly reviewed mail action and cooldown. The dialog shows no setup URL, recovery proof or raw transport error.

Separate API/CLI company creation retains its existing contract: local creation without automatic mail. An agent reads the new `owner_user_id` and its current version, then explicitly chooses `admin users send-setup-email`. The native receipt and administration interface therefore provide no extra OAuth identity or hidden supplier proxy.

`ApiCompanyOnboardingTest` proves version/context checks, preview, explicitly no mail, local rollback, actor revocation after commit, lost mail response and recovery after a lost receipt write. `CompanyOnboardingDurabilityTest` executes the same native requests on a restricted temporary socket-only MariaDB instance. A second connection sees company and owner before SMTP, reads the same receipt during the attempt and causes no extra email. No customer mailboxes or suppliers are contacted.

### Automatic WeFact linking during billing

The shared billing resolver uses the current company binding. A missing or different response for an existing positive WeFact debtor ID never creates a replacement debtor. Archived companies also retain their existing debtor ID and explicit administration reference; another company cannot adopt them.

A matching email address, VAT/Chamber of Commerce number or company name is only a review suggestion. Such a match grants no access to existing invoices or direct debit agreements. Only a unique existing administration reference can be linked automatically after an exact `debtor.show` check. An administrator must explicitly review other suggestions; a previously rejected candidate is not linked anyway.

If a new contact is required, the resolver records the creation attempt before the single `debtor.add` call. Parallel requests and a lost response start no second creation. A known received ID permits only validated readback; an unknown outcome remains unresolved. This shared billing check does not by itself expose new WeFact administration commands, imports or supplier changes through the API.
