Provisioning with SCIM
Sync accounts and groups from Entra ID, Okta or your own script: the base URL, the token, what the endpoints answer, and how each SCIM attribute maps to a mailbox.
If your people already exist in an identity provider, SCIM lets it create their mailboxes, keep their names in step and close their accounts when they leave — without anyone typing the same person in twice.
Nixt Server speaks SCIM 2.0 (RFC 7643 and RFC 7644), the protocol Microsoft Entra ID, Okta, OneLogin and most HR systems already speak.
Everything SCIM does goes through the same path as the admin API: the caller’s role is checked, the change is made, and the audit line is written, all in one transaction. An account your provider creates carries the same audit trail as one you create yourself.
Turn on the listener
SCIM answers on the same HTTPS listener as the admin API, so if you have already set one up there is nothing to add:
[listeners.admin]
bind = "0.0.0.0:443"
Mint a token
Your provider authenticates with a provisioning token — a credential for a machine. Mint one at the terminal, as the service user:
sudo -u versealx versealx-server admin post tenants/1/provisioning-tokens "label=Entra ID provisioning" scope=admin:tenant days=365
| Field | Meaning |
|---|---|
label | What to call it, so a list of them is a list you can act on. |
scope | What it may do. admin:tenant for a provider that manages the whole tenant; admin:domain:example.com to limit it to one domain. |
days | How long it lives, 1 to 365. 90 if you leave it out. |
The answer carries the secret:
{
"id": 1,
"label": "Entra ID provisioning",
"scope": "admin:tenant",
"tail": "E6TA",
"expiresAt": 1821074784000,
"secret": "vsxp_1_1_Kx8w…E6TA",
"note": "This is the only time the secret is shown. It cannot be recovered; mint another and revoke this one."
}
Copy it into your provider now. Only a hash of it is stored, so it cannot be shown again — if you lose it, mint another and revoke this one.
Looking after tokens
sudo -u versealx versealx-server admin get tenants/1/provisioning-tokens
sudo -u versealx versealx-server admin delete tenants/1/provisioning-tokens/1
The list shows each token’s label, scope, the last four characters of its secret (to match it against a configuration file), when it was last used, and whether it has expired. Revoking one stops it working immediately.
You can only mint a token for what you already hold: a tenant administrator cannot mint an operator token, and only administrative scopes are granted here — a provisioning token cannot read anyone’s mail.
A token belongs to the administrator who minted it. It stops working once that administrator could no longer mint it — their role taken away or made smaller, or their account disabled — so tokens do not outlast the person who made them. A token the operator mints over the local socket belongs to the installation and is unaffected. A provisioning token cannot mint another.
Point your provider at it
| Setting | Value |
|---|---|
| Tenant URL / base URL | https://mail.example.com/scim/v2/tenants/1 |
| Secret token | The secret from above |
Replace 1 with your tenant id, which versealx-server admin get tenants prints. Your provider appends /Users and /Groups itself.
Most providers offer a Test connection button. It fetches ServiceProviderConfig, which tells it that PATCH, filtering and password setting are answered, and that bulk operations, sorting and ETags are not.
Users
| SCIM attribute | In Nixt Server |
|---|---|
userName | The mailbox address. It must be in a domain this tenant owns. |
displayName | The account’s display name. If your provider sends no displayName, name.formatted — or the given and family names together — is used. |
name.givenName, name.familyName | Kept alongside the account and answered back. |
active | true is an ordinary account; false offboards the person with the organisation’s default choices, so mail goes on arriving and is handed on (see When somebody leaves), and they read as active: false. With offboarding.on_deprovision set to disable, false disables the account instead, and turning it back on restores it. |
password | Sets the account’s password, checked against your password policy. It is never answered back and never appears in the audit log. |
externalId | Your provider’s own identifier for the person. Unique within the tenant, which is what stops a second sync creating a second copy of everybody. |
emails | The account’s addresses, newest last, the primary first. Read-only. |
groups | The groups the person is in. Read-only. |
userName cannot be changed once the account exists. An address is where mail is delivered — mail is in flight to it, messages already delivered were addressed to it, and rules elsewhere name it — so changing one is a move rather than a field update, and Nixt Server asks you to do it deliberately rather than as a side effect of a sync. The published schema marks the attribute immutable, and a PUT or PATCH that would change it is answered 400 with scimType: "mutability".
emails is read-only for the same kind of reason: an account’s addresses belong to the directory, and userName is the one this endpoint sets. Add further addresses with the admin API or as aliases.
Deprovisioning
DELETE /Users/{id} does two things in one write: it takes the person out of every group they were in, and it offboards them with the account removed after 90 days (or, with offboarding.on_deprovision set to disable, deprovisions the account). It has to be one write — a distribution list is exactly how somebody who has left goes on receiving mail, and two requests would leave a window between them.
The account then answers 404 to SCIM, as a deleted resource should. It is not erased: the record, the mailbox and the audit trail stay where they are, and your retention policy decides when the mail goes. If the same person is provisioned again later, their externalId is free to use, because the provider’s claim on them was released with the account.
To stop someone signing in without removing them, set active to false instead.
Signed in as a person
SCIM is meant for an identity provider’s provisioning token. When an administrator calls it with their own signed-in token instead, it asks what the admin API asks:
- Removing somebody —
DELETE /Users/{id}, aPATCHorPUTthat setsactivetofalsewhere deprovisioning offboards, orDELETE /Groups/{id}— needs a recent sign-in, as it does in the console (401otherwise). Where the organisation has a second administrator approve removing people, SCIM answers403: an approval cannot be waited for here, so ask through the console or the admin API. - The operator, in an organisation that has its operator ask first, reads nothing through SCIM until an administrator has approved their access.
A provisioning token is not asked either: it belongs to no person and has nobody to ask.
Groups
| SCIM attribute | In Nixt Server |
|---|---|
displayName | The group’s name. |
members | The accounts and groups in it, by id. Nested groups work. |
externalId | Your provider’s identifier for the group. |
A group need not have an address. Most groups a provider syncs are a set of people — used to decide who gets what — rather than something mail is sent to, and one that is never sent to has no business owning an address.
A group that is a distribution address says so through this server’s schema extension, since the core SCIM schema has nowhere to put one:
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
"displayName": "Engineering",
"members": [{ "value": "2" }],
"urn:ietf:params:scim:schemas:extension:versealx:2.0:Group": {
"emailAddress": "eng@example.com",
"sendRestriction": "tenant"
}
}
sendRestriction is anyone, tenant (only senders inside this tenant) or members.
Filtering
Filters answer one attribute at a time, with eq or pr:
userName eq "ada@example.com"
externalId eq "00u1a2b3c4"
displayName eq "Engineering"
userName pr
That is what a provider actually asks: is this person already here?, and find the one I know by this identifier. A filter outside that — and, or, not, co, sw, a value path — is answered 400 with scimType: "invalidFilter", naming what was not understood. It is refused rather than answered with a partial match on purpose: a provider that asks “is anyone called this?” and is told “no” creates a second copy of somebody who is already here.
Paging
startIndex (counting from 1) and count, as SCIM defines them. A page holds at most 200, and every answer carries totalResults so your provider knows whether to ask for another.
GET /scim/v2/tenants/1/Users?startIndex=201&count=200
Errors
Refusals are SCIM error documents, with the status repeated in the body as a string:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"scimType": "uniqueness",
"detail": "ada@example.com is already taken",
"status": "409"
}
| Status | When |
|---|---|
400 | The document is not what it claims to be, a value is not of the right kind, a filter cannot be answered, or a PATCH names an attribute this endpoint does not write. |
401 | No credential, or one this server does not take. |
403 | The token’s scope does not cover this tenant or this domain. |
404 | No such resource — including one that has been deprovisioned. |
405 | The method is not one that path answers. |
409 | An address or an externalId is already taken. |
What is provisioned, and what is not
SCIM sees users and groups. Aliases and calendar resources are managed with the admin API; they are not SCIM resources and do not appear in a listing.
If your people are in LDAP or Active Directory
SCIM is for a provider that pushes changes to you. If your directory is one you read instead — Active Directory, OpenLDAP — see Syncing from LDAP or Active Directory. A tenant should be provisioned from one of the two, not both; a sync never touches accounts SCIM created, so if you do run both they will not fight, but you will have two things deciding who exists.
Something unclear or out of date on this page? Tell us.