SSO and user provisioning
Two questions that are easy to conflate and have different answers: how a person signs in, and how their user record comes to exist. Sign-in is federation into SpendOne's identity provider. Provisioning is one of three paths you choose from.
Neither applies to an integration itself: a machine caller authenticates with a bearer token and never signs in interactively. See API Basics.
How sign-in works
SpendOne authenticates through OpenID Connect, brokered by Zitadel. Your own identity provider federates into Zitadel, not into SpendOne, so there is no SSO configuration screen inside the product and no SAML endpoint on the API.
Entra ID, Google Workspace, Okta, Ping, OneLogin and ADFS are all connected the same way: as an upstream provider on the Zitadel organization that represents your tenant, alongside generic OIDC (a Shibboleth bridge, for example). Ask API support at tech@spendone.com to set that up; it is not self-service today.
Two identities exist for every person, and keeping them apart is what makes the rest of this page make sense:
- The Zitadel user is the authentication identity: the federated IdP account, or a local password or passkey.
- The SpendOne user is the authorization record in your tenant: roles, permissions, org unit, cost centers, everything in Core Concepts.
SpendOne is the source of truth for who is allowed and what they may do. Zitadel is a thin authentication broker.
The two tenant modes
A tenant runs in one of two modes, set by SpendOne rather than in the dashboard, with a fail-secure default of closed:
sso | closed | |
|---|---|---|
| Creating a user in the portal | Writes the SpendOne record only. No account is minted, no mail is sent | Creates the Zitadel user and sends the invite/setup mail |
| First login, no matching record | The record is created on the spot, so no valid IdP user is locked out | Rejected with 403 and an audit entry |
| First login, record exists | The IdP identity is linked to it once, by email, and the link is audited | Not applicable; the record already carries its identity |
| Deactivated SpendOne user | Blocked, even when the IdP login still succeeds | Same |
Three consequences an integration should build for:
- Users are matched on a permanent identifier, never on email. The token's subject is stored on the record at first link, and everything afterwards keys on that. An email change mutates the same record; it can neither break the link nor hijack another one.
- Roles assigned in SpendOne stay authoritative. A login never reconciles roles from token claims, so it cannot downgrade someone your integration promoted.
- Deactivation is the offboarding switch, and it holds regardless of what the IdP still allows.
The employee_id claim
Where the Zitadel SSO connection carries an employee_id custom claim, it is written to the user's external_id on every login. The IdP wins when the claim is present, and an absent or empty claim never clears a stored value.
That matters if your integration also writes external_id (the CSV import does): the two writers converge on the IdP's value once the person logs in. A value that already belongs to a different user is skipped rather than applied, because a login never fails over an identifier collision, and the skip is audited.
How user records get created
Three paths, in descending order of how much you should prefer them.
1. SCIM 2.0
The right path when your IdP is the system of record for joiners, movers and leavers. Your IdP pushes changes and SpendOne applies them: no file exchange, no scheduled job.
The endpoints are the standard ones, under a per-tenant base URL:
/scim/v2/{tenant}/ServiceProviderConfig
/scim/v2/{tenant}/ResourceTypes
/scim/v2/{tenant}/Schemas
/scim/v2/{tenant}/Users GET, POST
/scim/v2/{tenant}/Users/{id} GET, PUT, PATCH, DELETE
What the service supports, as ServiceProviderConfig reports it: PATCH yes, filtering yes (capped at 100 results), bulk no, sort no, ETag no, change-password no. Filtering is deliberately narrow: userName eq "…" and externalId eq "…", the two filters an IdP connector actually sends. Anything else is rejected rather than silently widened.
Tokens are managed separately from user sessions and separately from the REST bearer token:
GET /api/v1/scim-tokens
POST /api/v1/scim-tokens
DELETE /api/v1/scim-tokens/{id}
A SCIM token carries the scim_ prefix and is returned once, at creation. Point your IdP's SCIM connector at the base URL above with one of them.
Attribute mapping. userName or the primary email becomes the user's email, lowercased (Entra sends the UPN in userName, which is handled). externalId becomes external_id. name.givenName and name.familyName become the names. From the enterprise extension:
| SCIM attribute | Resolves to |
|---|---|
department | An organizational unit, matched by name. No match, or an ambiguous one, is flagged rather than guessed |
costCenter | A cost center |
manager.value | The reporting manager, looked up by email. A cycle is refused |
organization | Stored verbatim, not resolved |
Resolution failures flag the user rather than failing the sync, so a department your org tree does not have yet does not block the person from being provisioned. Where a resolved manager differs from the matched unit's leader or deputy, that is recorded as an advisory: the two are different relationships, and SCIM only speaks to the first.
2. CSV import
Best for the initial load, and for a directory you export from rather than one that pushes. See Data import via blob storage, or POST /api/v1/admin/dataimport/kinds/users/upload for a one-off file.
3. Direct API calls
For corrections and small changes: POST /api/v1/users, PUT /api/v1/users/{id}, PATCH /api/v1/users/{id}/deactivate. See Push users and permissions.
Choosing
| Your situation | Use |
|---|---|
| IdP owns joiners, movers and leavers, and can push | SCIM |
| HR system can export on a schedule, but not push | CSV via blob storage |
| Neither; a person or a script maintains the directory | REST API |
| Going live from nothing | CSV for the load, then SCIM or REST for the steady state |
Related
- Customer Onboarding for where identity sits in the go-live sequence. It is the first blocking milestone, because email matching is what links imported users to their IdP identity.
- Roles and Permissions for what to provision alongside the person.
- API Basics for bearer tokens and the error model.