Users & organizations
How to read the current user, their organization, and other members — the token claims, the me endpoints, OIDC userinfo, and the exact calls in every SDK.
Identity lives in three places
For provisioning and offboarding through SCIM or Google Workspace, see Directory provisioning.
Every integration eventually asks the same two questions — who is the current user? and which organization are they acting in? The platform answers them on three surfaces, and knowing which to use saves you from parsing things that were never meant to be parsed:
- The access token carries identifiers only —
sub,user_guid,org_guid,role,context. Per RFC 9068 it deliberately never carries email, names, or avatars, so a leaked token exposes no profile data and a renamed user never holds a stale token. Use it to know which user and org a request belongs to, not to display anything. - The "me" endpoints return the profile: a full record from the Control Plane and a lightweight identity from AuthN. This is where email, display name, org name and MFA state live.
- The OIDC
userinfoendpoint is the standards-compliant surface — any off-the-shelf OIDC client library can consume it without knowing anything platform-specific.
The rest of this page documents each surface exactly, then the org-side equivalents, then how to reach all of it from every SDK.
The current user
GET /v1/control-plane/portal/me — the profile of record
The canonical profile endpoint. Portal bearer required; returns the platform's standard envelope with the full profile as data:
{
"message": "Profile retrieved",
"success": true,
"status": "success",
"data": {
"guid": "22222222-2222-2222-2222-222222222222",
"user_guid": "22222222-2222-2222-2222-222222222222",
"org_guid": "11111111-1111-1111-1111-111111111111",
"email": "ann@example.com",
"email_verified": true,
"first_name": "Ann",
"last_name": "Smith",
"display_name": "Ann Smith",
"avatar_url": "https://…/ann.png",
"is_active": true,
"last_login_at": "2026-07-29T09:12:33+00:00",
"mfa_enabled": false,
"mfa_method": null,
"created_at": "2025-11-02T10:00:00+00:00"
}
}| Field | Type | Nullable | Notes |
|---|---|---|---|
user_guid | string (UUID) | no | The user's stable identifier. Paired with org_guid, this is what the consumer SDKs' gate 1 (authenticate) resolves. |
org_guid | string (UUID) | no | The organization of the presented token — taken from the JWT claim, never from a header. |
guid | string (UUID) | no | Same value as user_guid; kept for existing consumers. |
email | string | yes | Served through the regional PII store; null if no PII record exists. |
email_verified | boolean | no | |
first_name / last_name / display_name | string | yes | |
avatar_url | string | yes | A URL string — there is no avatar upload endpoint; set it via PUT /me. |
phone, job_title, department, locale, timezone, preferred_language | string | yes | The standard profile fields; null until set by the user, an admin, SCIM or directory sign-in. |
custom_attributes | object | no | The org-defined attribute values on your membership in this org, keyed by attribute code. Only codes whose definition is live appear; a retired definition hides its value. {} when the org defines nothing. |
is_active | boolean | no | |
last_login_at | string (ISO-8601) | yes | |
mfa_enabled | boolean | no | MFA enrolment lives at /portal/mfa/* (status, enroll, confirm, disable, backup codes). |
mfa_method | string | yes | e.g. "totp" when enabled. |
created_at | string (ISO-8601) | yes |
Errors: 401 for a missing/invalid/expired bearer, 404 if the token's subject no longer exists.
PUT /v1/control-plane/portal/me — update your own profile
Partial-update semantics: send only the fields you change. Accepted fields are first_name, last_name, display_name, avatar_url and the six standard profile fields phone, job_title, department, locale, timezone, preferred_language — all optional strings — plus custom_attributes, an object keyed by the org's attribute codes. A 422 names the field and the rule, never the value: phone must match ^\+?[0-9 ()-]{6,40}$, locale / preferred_language must have BCP-47 shape (en, en-GB), timezone must be an IANA zone name.
custom_attributes is a partial merge over the values stored on your membership in the token's org: codes you omit are kept. Only codes whose definition is marked self-editable may be written here — any other code is a 403 naming it, an unknown code or a wrong type is a 422 naming it, and a refusal writes nothing at all. The response echoes {guid, email, first_name, last_name, display_name, avatar_url}, the six profile fields and the resulting custom_attributes.
Email and password are not editable here. There is deliberately no in-session password change — password resets always go through the email-proving flow at POST /v1/authn/public/forgot-password, so control of the inbox is demonstrated and MFA is enforced at reset time.
GET /v1/authn/portal/me — the lightweight identity probe
AuthN's /me answers "is this session valid, and who is it?" in one call — it is what the frontend SDK's auth gate probes on every page load. The response nests under user and adds the org's display fields, which the Control Plane profile does not carry:
{
"user": {
"user_guid": "22222222-2222-2222-2222-222222222222",
"org_guid": "11111111-1111-1111-1111-111111111111",
"email": "ann@example.com",
"display_name": "Ann Smith",
"name": "Ann Smith",
"org_name": "Acme, Inc.",
"org_subdomain": "acme",
"role": "admin",
"context": "portal"
}
}display_name falls back to first_name last_name, then to the email local-part, so it is always renderable. Errors: 401 means re-authenticate; 503 means the identity backend is temporarily unreachable (the caller should retry, not log the user out — the SDK's gate does exactly that). CP operators have the mirror endpoint at GET /v1/authn/cp/me.
The OIDC userinfo endpoint
For standards-compliant clients, the discovery document at {issuer}/.well-known/openid-configuration advertises userinfo_endpoint: {issuer}/v1/authn/public/oidc/userinfo (GET or POST, Authorization: Bearer with an access token from the OIDC flow). Claims are gated by the token's granted scopes, and null claims are omitted:
| Claim | Requires scope | Notes |
|---|---|---|
sub | — | Always present. |
email, email_verified | email | |
name, given_name, family_name, picture, preferred_username | profile | name and preferred_username are the display name. |
updated_at | profile | Unix epoch seconds of the last login. |
org_guid, org_name, role | — | Platform claims, returned whenever present regardless of scope. |
Supported scopes are openid profile email org role offline_access. Note that only tokens minted through the OIDC authorization-code exchange carry a scope claim — a token from the credentials login degrades to sub plus the platform claims, so prefer the /me endpoints unless you specifically need the standards surface.
What the access token itself carries
If you decode the bearer (server-side, RS256-verified — see Auth & the five gates), the claim set is:
| Claim | Meaning |
|---|---|
sub / user_guid | The user (both carry the same GUID). |
org_guid | The organization the token was minted for. |
role | The user's org-level role at mint time. |
context | Trust tier: "portal" for tenant users, "cp" for operators. |
token_type | Always "access". |
client_id, jti, iss, aud, iat, exp | Standard OAuth2/JWT plumbing; aud is tier-suffixed (<base>:portal / <base>:cp). |
There is no email and no name in the access token — that is by design, not an omission. Anything user-visible comes from the endpoints above.
The current organization
Resolving the org — before and after login
Before login, a frontend knows only its hostname. The public bootstrap endpoint resolves it:
GET /v1/control-plane/public/bootstrap?hostname=acme.yourapp.exampleNo auth required. Among the response's top-level fields, organization carries the org identity — {guid, slug, name, type, subdomain} — alongside portal_url and the resolved app/instance. Branding (logo, favicon) arrives in the same response inside the per-app resolved config values, so a white-label frontend can paint itself before any user signs in. A hostname that maps to no org returns a platform-scoped bootstrap with organization: null.
After login, the token's org_guid claim is the current org. To enumerate every org the user belongs to (multi-org users picking a workspace), call:
GET /v1/control-plane/portal/user-contextThe response's data is {user, memberships}: user is {guid, email, email_verified, is_active, display_name, has_password}, and memberships is a list of {organization_id, organization_guid, organization_slug, role, is_active} — one row per org, soft-deleted memberships excluded. Note memberships carry the org slug, not its display name; resolve the name from bootstrap or keep it from sign-in.
The org's commercial state — plan, subscription status, and enabled features — comes from GET /v1/control-plane/portal/features, which returns {plan_code, plan_tier, subscription_status, features} for the token's org (see Subscriptions & billing).
The billing profile
GET /v1/control-plane/portal/billing-profile returns the org's billing record: name, legal_name, billing_email, tax fields (tax_id, tax_id_type, tax_exempt, company_registration_number), the billing address, billing_currency, payment_terms, invoice settings (invoice_prefix, invoice_locale, additional_invoice_emails), and purchase-order fields. Any active member may read it.
PUT /v1/control-plane/portal/billing-profile updates it — admin/owner only. The gate checks the caller's role through AuthZ and fails closed: not an admin → 403; AuthZ unreachable → 503, never a silent allow.
Deliberately absent from the portal tier: changing the org's name, slug, or subdomain. Those are operator actions on the Control Plane, not tenant self-service — a portal integration should not attempt them.
Org-scoped settings and branding
Org-configurable app settings (including branding your app declared in its manifest) are read at GET /v1/control-plane/portal/config-v2/org/{app_code}/values — the resolved tree with org → platform → default fallback — and written per section with PUT …/sections/{section_path}, which requires the org_config.update permission and an edit lock. The scope is always forced to the caller's own org; the request cannot address another org. See Settings & config for the manifest side.
Other users in the org
GET /v1/control-plane/portal/members — the members list
Any active member can list the org's members — this is also the supported way to resolve a user GUID to a name or email for display. Rows are membership records with the user's identity joined in:
{
"success": true,
"status": "success",
"message": "Members retrieved",
"data": [
{
"guid": "…",
"role": "admin",
"is_active": true,
"joined_at": "2026-01-12T08:00:00+00:00",
"user_guid": "22222222-2222-2222-2222-222222222222",
"user_email": "ann@example.com",
"user_first_name": "Ann",
"user_last_name": "Smith"
}
],
"meta": { "total": 1, "page": 1, "per_page": 10, "total_pages": 1 }
}Query parameters:
| Param | Notes |
|---|---|
page | 1-based, default 1. |
per_page | Default 10, maximum 100 — requesting more returns 422. Paginate; never bulk-fetch. |
filters[<field>]=v / filters[<field>][<op>]=v | Field filters with operators (eq, icontains, …). Dotted paths reach the joined user: filters[user.email][icontains]=ann. |
filters[role,user.email,user.first_name,user.last_name][icontains]=ann | The OR-search form — one term matched across several fields; this is the typeahead search for member pickers. |
sort | sort[joined_at]=desc or sort=-joined_at. Default created_at descending. |
The org filter is enforced server-side from the token — a client-supplied organization filter is overwritten, so the list can never cross tenants.
Two companions: GET /portal/members/seat-usage returns {members, pending_invites, used, limit, unlimited, at_limit} for seat-limit UI, and GET /portal/members/{membership_guid} returns a single membership record — note it does not hydrate the user's email/name; use the list endpoint when you need identity fields. Member role changes (PUT /portal/members/{membership_guid}) and removals are admin/owner-gated, and invitations live under /portal/invitations.
Accepting an invitation
When someone is invited to a workspace, they receive an email with a one-time token. Pending invitations are listed with GET /v1/control-plane/portal/invitations. There are two ways to accept an invitation:
| The invitee | Call |
|---|---|
| has no account yet | POST /v1/control-plane/public/invitations/accept with {"token": "...", "password": "...", "first_name": "...", "last_name": "..."}. No sign-in is needed; the token is the proof. It creates the account with the email already verified, adds the membership and returns {"user_guid", "created_user"}. Then sign the user in as usual. |
| already has an account in that organization | POST /v1/control-plane/portal/invitations/accept/{token} while signed in. The signed-in user's email must match the invitation's. |
An expired, used or unknown token answers 404 "Invitation not found or expired."
Per-app-instance members
GET /v1/control-plane/portal/applications/{instance_guid}/members returns the same member rows scoped to one app instance, each with org_role and instance_role — instance_role: null means the member inherits their org role on that instance. PUT …/members/{membership_guid}/role (admin/owner) sets the per-instance override. See Identity & access for how instance roles are declared.
Service-to-service lookups (internal tier)
A backend app resolving actor GUIDs for display — audit trails, comment authors, assignee chips — uses the internal tier with its x-api-key, not a user bearer:
| Endpoint | Purpose |
|---|---|
GET /v1/control-plane/internal/users/{user_guid} | One GUID → {guid, email, first_name, last_name, display_name, avatar_url}. |
POST /v1/control-plane/internal/users/batch-lookup | Many GUIDs in one call → [{guid, email, display_name, avatar_url, type, is_deleted}]. Includes soft-deleted users so historical records render as [deleted] rather than blank — the pattern for audit-trail display. |
GET /v1/control-plane/internal/users/search?org_guid=…&q=…&limit=… | Org-scoped fuzzy search over name/email (limit ≤ 50) — the backing for mention widgets and member autocompletes. |
Resolve only the GUIDs actually visible on screen, batch them, and cache per render — never sweep the whole user table to build a lookup map.
Getting the user and org in every SDK
Python — egav-app-sdk (building an app)
Inside a platform-native app, the request context already carries the identity — no HTTP call needed:
from egav_app_sdk import get_app_context, AppContext
from fastapi import Depends
@router.get("/v1/my-saas/portal/projects")
async def list_projects(ctx: AppContext = Depends(get_app_context)):
ctx.user_guid # the caller
ctx.org_guid # their org
ctx.tenant_application_instance_guid # the tenant instance
ctx.plan_tier, ctx.features, ctx.quotas # entitlements snapshot
ctx.timezone # the user's timezoneAppContext has no role field by design — permission questions go through requires_permission, not role string comparison. Brownfield services using the FastAPI dependencies get the same identifiers from request.state.identity (user_guid, org_guid, role) after validate_portal_bearer runs. For the profile (email, display name), call GET /v1/control-plane/portal/me — or, when rendering other users, the internal batch-lookup above.
TypeScript — @egav/app-sdk (browser)
Three levels, from cheapest to fullest:
// 1) Installed app: user + org + entitlements from the app context.
import { useAppContext } from '@egav/app-sdk';
const { user, org } = useAppContext(); // user: { guid, email, name, roles } | null
// 2) Session gate: probes AuthN /v1/authn/portal/me, returns a light identity.
import { useAuthGate, probeCurrentUser } from '@egav/app-sdk/auth';
const { status, user: probed } = useAuthGate(session, { resolveAuthnBaseUrl });
// probed: { guid, email?, displayName?, role?, orgGuid? } | null — tolerate null.
// 3) Full profile: call the Control Plane with the token-refreshing fetch.
import { apiFetchWithAuth } from '@egav/app-sdk/auth';
const res = await apiFetchWithAuth(`${cpUrl}/v1/control-plane/portal/me`, {}, hooks);
const profile = (await res.json()).data; // the full schema documented aboveAlongside these: usePermissions().role exposes the resolved role, useOrg() exposes the membership list and active-org switcher, and createUserStorage (from @egav/app-sdk/user-storage) caches {name, email, guid} for instant header rendering between loads. The SDK does not export a JWT claims parser — treat the token as opaque in the browser and read identity from these surfaces.
Go, Node, PHP, Rust — the consumer clients
In the backend consumer SDKs, gate 1 (authenticate) is the current-user primitive: it validates an inbound end-user bearer against GET /v1/control-plane/portal/me and resolves the identity pair, failing closed (503) when the Control Plane is unreachable:
id, err := gates.Authenticate(ctx, userBearer) // Identity{ UserGuid, OrgGuid }const id = await gates.authenticate(userBearer); // { userGuid, orgGuid }$id = $gates->authenticate($userBearer); // Identity{ userGuid, orgGuid }let id = gates.authenticate(user_bearer).await?; // Identity { user_guid, org_guid }Identity is deliberately two fields — it answers who and which org, not what may they do (that is gate 2, requirePermission). For the full profile or the members list, use the generic typed client against the endpoints documented above, supplying your own result type:
type Me struct {
UserGuid string `json:"user_guid"`
OrgGuid string `json:"org_guid"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
}
res, err := egavsdk.Get[Me](ctx, c, "control-plane", "/v1/control-plane/portal/me")
// res.Data.DisplayName …// Same pattern in Node — the members list with a typeahead filter:
const res = await client.get(
'control-plane',
'/v1/control-plane/portal/members?filters[role,user.email,user.first_name,user.last_name][icontains]=ann&per_page=20',
);
for (const m of res.data) console.log(m.user_email, m.role);The PHP and Rust clients follow identically — $client->get('control-plane', '/v1/control-plane/portal/me') and client.get::<Me>("control-plane", "/v1/control-plane/portal/me"). One rule holds across all four: the end-user bearer goes only to portal/* routes, and the app's x-api-key goes only to internal/* routes (like the batch user lookup) — the two credentials never cross.
Directory provisioning
Sign-in and provisioning are separate connections. SAML or OIDC authenticates a person; SCIM or directory sync creates, updates and deactivates their account. Configuring sign-in alone does not establish offboarding.
Choose push SCIM or Google Workspace pull
An organization can accept SCIM 2.0 requests from a compatible identity provider at /v1/control-plane/scim/v2/{org_guid}. The organization-specific SCIM bearer token authenticates that protocol. Read the saved configuration's endpoint rather than constructing a private service URL.
Google Workspace supports automatic push provisioning for supported catalog applications. For a custom SynaptaGrid application, use Google directory pull: SynaptaGrid reads a configured Workspace group through the Admin SDK and applies the existing SCIM account lifecycle. A push bearer token is not required for this connection.
The organization's plan must include SCIM and organization SCIM provisioning must be enabled. Its connection is stored at organization scope, with an encrypted secret and no inheritance from the platform's operator connection.
Organization connection
Configure these manifest fields through the organization configuration API or the portal's organization settings:
| Field | Meaning |
|---|---|
security.tenant_directory.source.enabled | Enable scheduled directory reconciliation. Defaults to false. |
security.tenant_directory.source.provider | Select google_workspace. |
security.tenant_directory.google_workspace.group_email | The private group whose members should be provisioned. |
security.tenant_directory.google_workspace.impersonated_admin | A Workspace administrator with permission to read users and group members. |
security.tenant_directory.google_workspace.service_account_key | The service account JSON key, stored encrypted and masked on reads. |
Enable the Admin SDK and authorize domain-wide delegation for exactly these read-only scopes:
https://www.googleapis.com/auth/admin.directory.group.member.readonly
https://www.googleapis.com/auth/admin.directory.user.readonlyThe adapter cannot write to Google Workspace. Use a dedicated group and a service account approved by the organization's Workspace administrator. Never place the JSON key in source, application logs, examples or request URLs.
Provisioned member access
Account provisioning and permission assignment are separate. On the portal's SCIM provisioning page, select the baseline workspace role for managed members, or configure group-to-role mappings through SCIM configuration. A null default_role_code means no baseline; group mappings can still grant access. The account's informational role label does not establish its resolved grants. Test the member's actual sign-in and permissions before expanding the group.
Saving role configuration reconciles existing managed accounts. Accounts that were created outside provisioning keep their own grants; explicit SCIM group participants can receive their mapped roles without acquiring a baseline. If configuration changes while a save's authorization checks are running, it returns 409: refresh the current configuration and review the change before retrying. This protects the server-side save sequence; the API does not accept a browser snapshot or version precondition.
Portal API
All portal routes derive the organization from the verified access token. Do not supply an organization identifier in a path, query or request body. Responses use the standard success, status, message, data envelope.
| Method and path | Result |
|---|---|
GET /v1/control-plane/portal/scim-config | This organization's SCIM settings, including default_role_code; credentials remain masked. |
PUT /v1/control-plane/portal/scim-config | Update supplied fields only, for example {"default_role_code":"user"}; validate the caller's grant ceiling and queue reconciliation atomically. |
GET /v1/control-plane/portal/scim-config/directory | Connection completeness and this organization's last directory run. |
POST /v1/control-plane/portal/scim-config/directory/test-connection | Read the saved group's members and return member_count; change no accounts or configuration. |
POST /v1/control-plane/portal/scim-config/directory/sync-now | Queue durable work and return job_guid, created_at, target_guid, status and existing_run. |
Status exposes enabled, provider, configured, missing_fields and last_run. A run contains its job and target identifiers, server creation time, status, safe error, result counts and update time. It never contains the connection's key. Manual and scheduled work share serialization and duplicate-run protection. An accepted enqueue is not proof that reconciliation succeeded; inspect the terminal run result.
Reads require organization SSO-configuration read permission; test and sync require update permission. Plan entitlement is enforced independently. Connection edits use the existing organization-configuration permissions. Control Plane operators have equivalent routes below /v1/control-plane/organizations/{org_guid}/scim-config/directory, guarded by organization read/update permissions. The operator status view can report entitled: false to explain an unavailable connection; operator test and sync actions still enforce the entitlement.
Account ownership and failure behavior
Only accounts this connection creates become directory-owned. A matching pre-existing account is a conflict, including an account provisioned through push SCIM; it is never silently adopted. Existing owners and service accounts remain unchanged. Existing SCIM role mappings and configured application provisioning determine the new member's access.
A stable Google user ID identifies an owned account. Removing a member from the group or suspending it deactivates its organization account, withdraws managed access and queues session revocation. Rejoining can reactivate the same account. A primary-email rename is reported for review because the SCIM account email is immutable.
A failed or partial directory listing applies no reconciliation. An empty listing that would deactivate every active owned member is refused. Queued work rechecks enablement and the connection fingerprint before writing. Changing the source group, delegated administrator or credential principal cannot silently offboard the old connection's users. Rotating an equivalent credential preserves ownership and rejects work queued against its old key.
Control Plane operator migration
Operator directory sync is configured separately at platform scope. Existing JIT operators can be explicitly migrated through:
POST /v1/control-plane/cp/cp-operators/{operator_guid}/directory-ownership
Content-Type: application/json
{"action":"adopt","expected_source":"jit"}The server verifies current membership and the exact Google identity. It preserves the operator's GUID, role, profile and sessions. Bootstrap, manual, invitation, push-SCIM and service-account records cannot be adopted. The automatic sync continues to skip records it does not own.
To stop managing an owned operator through Google, send {"action":"release","expected_source":"google_workspace"}. This preserves its current role and active state and makes it manually managed. It does not reactivate an inactive operator.
The result contains operator_guid, provisioning_source and changed. Both operator-update and directory-configuration-update permissions are required, along with the operator role hierarchy check. A stale source, changed connection or conflicting identity returns a conflict without modifying the operator. Keep a bootstrap administrator outside directory ownership.