Portal SSO
Portal SSO lets your end-customers sign in to the LedgerBee customer portal through your own login, and lets your backend provision net-new customers on demand. Your operator configures SSO (OIDC, back-channel handoff, claim mapping) under Portal → Single sign-on and hands you a Partner SSO API key for the Portal SSO endpoints in the API Reference.
The Partner SSO key and its scopes
Partner calls authenticate with the x-api-key header — see
Authentication. The tenant is resolved server-side
from the key; never put a tenant id in a request body.
| Scope | Authorizes |
|---|---|
portal-provision | POST /v1/portal-sso/provision |
portal-sso-mint | POST /v1/portal-sso/handoff/mint, POST /v1/portal-sso/sessions/revoke, and the same-call handoff mint inside provision |
These scopes cannot be assigned through the generic API-key or OAuth-client
paths (API_KEY_SCOPE_NOT_ASSIGNABLE); the operator mints the key via
Generate SSO key on the SSO tab, and the plaintext is shown once.
The tenant must also hold the CustomerPortal license. Without it, every
Portal SSO endpoint returns 403 with code: "LICENSE.REQUIRED" and
details.licenseKey: "CustomerPortal", regardless of the key's scopes — see
Errors.
Provision a customer
POST /v1/portal-sso/provision (scope portal-provision) upserts a customer
and grants a credential-less portal user an active membership in one idempotent
call, so a net-new end-customer can arrive via SSO. No invitation email is sent.
Identify the customer with exactly one of:
customerId— an existing customer, never mutated.customer— an idempotent upsert keyed oncustomer.customerNumberwithin your tenant: an existing match is reused as-is; otherwise a customer is created. Required when creating:customerType,customerNumber,name,countryCode,vatZone(lowercase:domestic,eu,abroad,domestic_without_vat), andcustomerGroupId(list your tenant's groups under the Customers tag in the API Reference).
Code
The top-level email (required) is the portal-user login. It is distinct from
customer.email, the customer's contact email, which falls back to it when
omitted. The response carries customerId, customerCreated, portalUserId,
membershipId, and role. The organization's first member is force-assigned
OWNER; re-provisioning reactivates a previously-removed membership.
mintHandoff: true additionally returns handoffRef / handoffExpiresAt for
a single round-trip "Go to portal". It requires the portal-sso-mint scope,
handoff enabled on the tenant, and sub.
Mint a handoff reference
The back-channel handoff signs a user in without a front-channel JWT: you
authenticate the user, mint a single-use opaque ref (the identity stays
server-side), and redirect the browser to the portal to redeem it.
POST /v1/portal-sso/handoff/mint (scope portal-sso-mint) responds with
{ ref, expiresAt }:
Code
email,sub(your stable user id), andmembershipsare required.- At least one membership is required. The portal user, organization, and
membership are created at redeem from what you assert; no prior provision
call is needed. Each
customerIdmust be a real customer of your tenant. roleis one ofOWNER,ADMIN,BILLING_ADMIN,USER.- When asserting multiple memberships, mark exactly one with
"primary": true— the organization the user is signed into. With a single membership the flag is optional.
Redeem the handoff
Return the user's browser to the portal-API host — not api.ledgerbee.com
where you minted, and not the portal SPA host (<slug>.portal.…), which serves
the static app and ignores the ref:
Code
The redeem resolves or creates the portal user, sets the session cookie
(1-hour maxAge), and redirects to the portal SPA plus returnTo (same-origin
relative paths only). The ref has a 60-second TTL and is consumed atomically.
Any failure (missing, expired, or replayed ref; no resolvable membership)
redirects to the tenant login screen with ?ssoError=1 rather than a raw
error body. Mint a fresh ref.
Revoke sessions (offboarding)
POST /v1/portal-sso/sessions/revoke (scope portal-sso-mint, not
portal-provision) with { "sub": "your-stable-user-id" } returns
{ revoked: <count> }. It immediately invalidates the user's live portal
sessions but does not prevent re-login — deprovision the user at your IdP for
that. An unknown sub returns 404; revoked: 0 means no active sessions.
OIDC login
OIDC login is the front-channel alternative: the portal redirects the browser to your OpenID Provider (OP), validates the returned ID token, and mints the session. You host the OP and emit the claims the operator configured.
- Register exactly the redirect URI shown on the operator's SSO tab
(
https://<slug>.portal-api.<zone>/api/auth/sso/oidc/callback); it is derived from the portal slug and not editable. - The ID token must pass: exact
issmatch,audequal to the configured client id, unexpired, JWS signature verifiable via your published JWKS, and thenonceminted at start.emailfalls back topreferred_username; the login fails if both are absent. - PKCE
S256is always sent — accept it even if your OP does not advertisecode_challenge_methods_supported. - LedgerBee always requests
openid email profile; operator-configured scopes are additive. If your IdP gates role/customer claims behind a custom scope (Okta'sgroups, an Entra API scope), the operator must add it — or the membership mapping comes back empty.
Claim mapping
Validated claims map to memberships of { customerId, role } — customerId is
your asserted customer id, not a LedgerBee-internal id:
- Single-org —
orgClaimName(customer id) +roleClaimName(role), both string-typed. - Multi-org —
membershipsClaimName: a JSON array of{ customerId, role }. A non-empty array supersedes the single-org pair. - An unmapped role or a
customerIdnot in the tenant fails the login closed. With no usable claims, the login succeeds and memberships are untouched. Asserted memberships reconcile on each login — dropped ones are deactivated, not deleted.
JWKS — verifying LedgerBee's client assertions
Under private_key_jwt client auth, your OP fetches LedgerBee's public signing
keys from a public, version-neutral endpoint (no /v1 segment):
Code
It returns an RFC 7517 JWK Set keyed by kid, containing the tenant's current
signing key plus the previous key during a rotation grace window. An unknown
slug returns an empty key set, never a 404. Responses are cached for 5
minutes, so re-fetch on an unknown kid. client_secret_basic is the default
and the safe minimum — confirm private_key_jwt with your operator before
relying on it.
Errors
| You observe | Cause | Fix |
|---|---|---|
403 INSUFFICIENT_PERMISSIONS | Key lacks the required scope (reason mint_handoff_requires_scope when mintHandoff: true without portal-sso-mint) | Have the operator regenerate the SSO key with the needed capability |
403 LICENSE.REQUIRED | The tenant doesn't hold the CustomerPortal license (details.licenseKey: "CustomerPortal") | Have your LedgerBee administrator activate the customer-portal module |
401 UNAUTHORIZED | Invalid / missing key, or IP not allow-listed | Send a valid x-api-key; check allow-listing with the operator |
403 "handoff is not enabled" | Mint called while the tenant's back-channel toggle is off | Operator enables handoff on the SSO tab |
400 on mint | Unknown role, a customerId outside your tenant, no memberships, or several without exactly one primary | Fix the asserted membership set |
Browser lands on login with ?ssoError=1 | Redeem of a missing / expired / used ref, or an OIDC start/callback failure | Mint a fresh ref; for OIDC, restart the login |