Skip to documentation

Exchange will be rebuilt separately and is unavailable through the API or CLI. Availability in this release

Workflow guides

Markdown

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.

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.

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.

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:

{
  "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.

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:

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:

{
  "action": "block",
  "items": [
    {"id": 8, "version": "<64-tekensversie van gebruiker 8>"},
    {"id": 9, "version": "<64-tekensversie van gebruiker 9>"}
  ]
}
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.