Self-service onboarding
Sign your own users up, let them create organizations, invite people, and assign roles — without a platform operator in the loop.
ADR-0079 lets an application onboard its own users end to end: your backend signs people up, they create organizations, invite teammates, and manage roles, all against your application's own realm. This is the sequence, in the order an integrator hits it.
This page describes a managed application: one whose realm this engine created, and whose users it creates for you. If you run your own authorization server — Better Auth, Entra, Auth0, your own Keycloak — read Bring your own identity provider instead. The credential minting below does not exist there, and neither do the invitation and team-organization routes.
Before you start
Two things need to exist before any of this works, and neither is something your backend does — both are a platform operator's job, once, before you integrate:
- Your application and its realm — created in the console or via
POST /v1/admin/applications. See Applications. - An application credential — the
client_id/client_secretpair your backend authenticates with below. It is minted separately from the application itself: a platform operator callsPOST /v1/admin/applications/$APPLICATION_ID/credentials/rotate(platform-admin only) and collects the one-time secret fromPOST …/credentials/secret. There is no self-service route for this — an application can never mint its own credential.
Ask your platform operator for $APPLICATION_CLIENT_ID and
$APPLICATION_CLIENT_SECRET; everything below assumes you already have them.
What onboarding is
An application signs up its own users into its own realm. Your backend calls
one endpoint with a person's email (or an already-registered subject); the
engine converges a realm user, an application account, an owned personal
organization, and that person's org-admin role over it, all in one call. From
there the person signs in, optionally creates further organizations, and
invites others into them.
Signup is server-side, and why
POST /v1/onboarding/signups is the application-credential lane's first
permitted route (ADR-0079 D4). It is gated on an application credential: a
token whose scheme is the application lane, carrying the reserved
application-admin role and a resolved application — never a person, never an
end user. The application is the principal's, never the request body's, so
a credential can only ever onboard into the realm that minted it.
That has one consequence an integrator must plan around: a browser can never call this route. There is no credential a browser can safely hold that would satisfy the gate, and there should not be — the gate exists so that only your backend, holding your application's own secret, can mint accounts in your realm. Build a thin endpoint of your own that receives a signup request from your frontend and calls this one server-side.
Authenticate your backend the same way it authenticates for any other
application-credential call — client_credentials against its own realm,
using the service-account client a platform operator provisioned for your
application:
curl -s "$ISSUER/protocol/openid-connect/token" \
-d grant_type=client_credentials \
-d client_id="$APPLICATION_CLIENT_ID" \
-d client_secret="$APPLICATION_CLIENT_SECRET"Then sign the person up:
curl -s -X POST "$BASE_URL/v1/onboarding/signups" \
-H "Authorization: Bearer $APPLICATION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email":"jane@acme.com"}'{
"account_id": "…",
"personal_org_id": "…",
"created": true,
"starter_credit": true,
"credit_skipped": false
}The call is idempotent: a retry after a lost response returns the same result
with created: false, which is why signup answers 200 rather than 201.
Pass subject instead of (or alongside) email to adopt a person who already
registered through the realm's own hosted front door, rather than having the
engine create them; naming a subject that conflicts with an email already bound
to someone else is refused with 409 rather than merged.
Signing a user in
Once a person exists, they authenticate directly against the identity
provider — never against the engine, which hosts no login page, no authorize
endpoint, and no callback. Run authorization code + PKCE against your
application's realm, the issuer at <base>/realms/<realm>. See
Authentication for the general
end-user lane, including RFC 9728 discovery so you never hardcode the
authorization or token endpoint.
Every realm already carries a public client, deeplinq-cli, but it exists for
the CLI's device-grant and loopback flows and registers wildcard redirect URIs
(http://localhost/*, http://127.0.0.1/*). Do not reuse it for a web
application. Register your own client with an exact redirect URI instead.
Wildcard loopback redirects and a shared client_id make revocation, redirect
policy, and audit attribution inseparable — a compromised or retired web client
should not force reissuing the CLI's, and a CLI request should never appear in
your web application's audit trail or vice versa.
Creating an organisation
A signed-in user creates further organizations under their own application:
curl -s -X POST "$BASE_URL/v1/orgs" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Acme Corp"}'{ "id": "…", "name": "Acme Corp" }The caller becomes the new organization's first org-admin, in both the
engine's own role record and the realm's membership. Their personal
organization from signup is untouched.
Switching organisations requires a new token. A token naming more than one organisation resolves to no tenant at all, by design — the engine will not pick one at random on the caller's behalf. Creating this organisation did not change the access token the caller is still holding; it still names whichever organisation they signed in for.
To act as the new organisation, re-authenticate and request it explicitly:
scope=openid organization:dl-<org id>The organization's realm alias is deterministic — dl- followed by the id
this call returned — so you can compute the scope value without a further
lookup.
There is no organisation-list route
There is no GET /v1/orgs, and none is planned as part of this flow. A person
cannot ask the engine which organizations they belong to. If your application
needs an organisation switcher, your backend has to track the memberships it
created (or ask the identity provider directly) — the engine will not answer
that question for you today.
Inviting people
An org-admin invites someone into their own organisation:
curl -s -X POST "$BASE_URL/v1/org/invitations" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email":"colleague@acme.com"}'{ "invited": true }Two preconditions gate it, and each refusal names the gap:
invitations_enabledin the application's onboarding settings.- SMTP configured on the realm — the invitation can never be delivered otherwise.
The invitation itself is Keycloak's own: pending state, expiry, and resend all stay the provider's, and the engine mirrors none of it. The engine's contribution is the authorization check, the organisation switch, the precondition checks, and an audit receipt of who invited whom.
Roles
Everything below is org-admin gated and always acts on the caller's own organisation — none of these routes take an organisation id, and that absence is itself the authorization.
List the roster (identity-provider membership joined with engine roles):
curl -s "$BASE_URL/v1/org/members" \
-H "Authorization: Bearer $ACCESS_TOKEN"{
"members": [
{ "subject": "…", "username": "jane", "email": "jane@acme.com",
"enabled": true, "principal_id": "…", "roles": ["org-admin"] }
]
}Grant or revoke org-admin:
curl -s -X PUT "$BASE_URL/v1/org/members/$SUBJECT/admin" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"admin":true}'Remove a member:
curl -s -X DELETE "$BASE_URL/v1/org/members/$SUBJECT" \
-H "Authorization: Bearer $ACCESS_TOKEN"If you bring your own identity provider
The roster and elevation routes above are the engine's realm-backed ones and
answer 412 for an external application: your membership lives at your
identity provider, and the engine keeps no second copy. To say who administers
one of your organizations, use your application credential:
PUT /v1/application/orgs/{engine_org_id}/members/{subject}/admin
Authorization: Bearer <application token>
Content-Type: application/json
{"admin": true}{subject} is the sub your tokens carry for that person. admin: true
enrols the principal if the engine has never seen them — a person you promote
before their first sign-in is granted at once — and grants org-admin;
admin: false revokes it, answering 200 with "principal_id": null when there
was nobody to revoke. Both directions are idempotent, so retry freely. Roles
never travel in tokens: the engine reads authority from this record on every
request, and a role claim in your JWT is ignored by design.
End to end
Everything above, threaded into one sequence — the order an integrator
actually runs it, from an application credential to an invited teammate
sitting as org-admin of your caller's organisation. Values carry forward
step to step; browser-side identity steps are marked as such, since they are
not curl-able.
-
Get an application token.
curl -s "$ISSUER/protocol/openid-connect/token" \ -d grant_type=client_credentials \ -d client_id="$APPLICATION_CLIENT_ID" \ -d client_secret="$APPLICATION_CLIENT_SECRET"{ "access_token": "…", "token_type": "Bearer", "expires_in": "…" }Call this
$APPLICATION_TOKEN. -
Sign Jane up.
curl -s -X POST "$BASE_URL/v1/onboarding/signups" \ -H "Authorization: Bearer $APPLICATION_TOKEN" \ -H "Content-Type: application/json" \ -d '{"email":"jane@acme.com"}'{ "account_id": "acc_jane", "personal_org_id": "org_personal_jane", "created": true, "starter_credit": true, "credit_skipped": false } -
Jane signs in — out of band, a browser redirect rather than a
curlcall: authorization code + PKCE against$ISSUER, as in Signing a user in. The result,$JANE_TOKEN, names her personal organisation from step 2. -
Jane creates "Acme Corp". Her personal-org token is enough — creating a business org only needs a signed-in user, not a token already scoped to the org being created.
curl -s -X POST "$BASE_URL/v1/orgs" \ -H "Authorization: Bearer $JANE_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"Acme Corp"}'{ "id": "org_acme", "name": "Acme Corp" } -
Jane re-authenticates as Acme Corp. Everything from here on is org-admin-gated and acts on the token's own organisation, which must now be
org_acmefrom step 4, not her personal org:scope=openid organization:dl-org_acmeCall the result
$ACME_TOKEN. -
Jane invites her colleague.
curl -s -X POST "$BASE_URL/v1/org/invitations" \ -H "Authorization: Bearer $ACME_TOKEN" \ -H "Content-Type: application/json" \ -d '{"email":"colleague@acme.com"}'{ "invited": true } -
The colleague accepts and signs in — out of band, at the identity provider: Keycloak's own invitation mail, their own password, then authorization code + PKCE scoped to
organization:dl-org_acme, same as step 5. Only after this do they carry a principal — the roster entry a member who has never signed in is missing. -
Jane looks up the colleague's subject.
curl -s "$BASE_URL/v1/org/members" \ -H "Authorization: Bearer $ACME_TOKEN"{ "members": [ { "subject": "sub_jane", "username": "jane", "email": "jane@acme.com", "enabled": true, "principal_id": "principal_jane", "roles": ["org-admin"] }, { "subject": "sub_colleague", "username": "colleague", "email": "colleague@acme.com", "enabled": true, "principal_id": "principal_colleague", "roles": ["org-member"] } ] } -
Jane grants the colleague
org-admin.curl -s -X PUT "$BASE_URL/v1/org/members/sub_colleague/admin" \ -H "Authorization: Bearer $ACME_TOKEN" \ -H "Content-Type: application/json" \ -d '{"admin":true}'{ "admin": true }The colleague is now
org-adminof Acme Corp.
Refusals
Every step above can refuse. The shape is always the same — a machine-readable
type and a human message, plus a field when the refusal is about one
particular input:
{ "error": { "type": "invalid_request", "field": "email",
"message": "a signup needs the person's email" } }| Route | Status | Message | Do this |
|---|---|---|---|
POST /v1/onboarding/signups | 400 | "a signup needs the person's email" | Pass a valid email address. |
POST /v1/onboarding/signups | 412 | "onboarding is not enabled for this application — enable it in its onboarding settings" | Have a platform operator enable onboarding for the application first. |
POST /v1/onboarding/signups | 409 | "that email is already bound to a different identity in this application" | Don't pass a subject that conflicts with an email already bound to someone else — reuse the bound subject, or sign up a different email. |
POST /v1/onboarding/signups | 412 | "the realm has no SMTP configured — signup by email cannot deliver the password mail; configure SMTP or sign the person up by subject" | Configure SMTP on the realm, or pass subject to adopt someone who already registered at the identity provider. |
POST /v1/orgs | 400 | "an organisation needs a name" | Pass a non-empty name. |
POST /v1/orgs | 412 | "onboarding is not enabled for this application — organisations are operator-created here" | Same fix as above — without onboarding enabled, only an operator can create organisations. |
POST /v1/orgs | 403 | "this account has reached its organisation-creation limit (N)" | Raise org_creation_bound in onboarding settings (default 5), or stop creating more under this account. |
POST /v1/org/invitations | 400 | "an invitation needs an address" | Pass a valid email address. |
POST /v1/org/invitations | 412 | "invitations are not enabled for this application — enable them in its onboarding settings" | Have a platform operator turn on invitations_enabled. |
POST /v1/org/invitations | 412 | "the realm has no SMTP configured — an invitation could never be delivered" | Configure SMTP on the realm first. |
Any /v1/org/* route (invitations, the roster, role changes, removal) | 403 | "org-admin required" | Only an org-admin of the caller's own organisation may call these — grant it first (see Roles). |