Guides

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_secret pair your backend authenticates with below. It is minted separately from the application itself: a platform operator calls POST /v1/admin/applications/$APPLICATION_ID/credentials/rotate (platform-admin only) and collects the one-time secret from POST …/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_enabled in 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.

  1. 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.

  2. 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 }
  3. Jane signs in — out of band, a browser redirect rather than a curl call: authorization code + PKCE against $ISSUER, as in Signing a user in. The result, $JANE_TOKEN, names her personal organisation from step 2.

  4. 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" }
  5. 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_acme from step 4, not her personal org:

    scope=openid organization:dl-org_acme

    Call the result $ACME_TOKEN.

  6. 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 }
  7. 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.

  8. 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"] }
    ] }
  9. 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-admin of 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" } }
RouteStatusMessageDo this
POST /v1/onboarding/signups400"a signup needs the person's email"Pass a valid email address.
POST /v1/onboarding/signups412"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/signups409"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/signups412"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/orgs400"an organisation needs a name"Pass a non-empty name.
POST /v1/orgs412"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/orgs403"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/invitations400"an invitation needs an address"Pass a valid email address.
POST /v1/org/invitations412"invitations are not enabled for this application — enable them in its onboarding settings"Have a platform operator turn on invitations_enabled.
POST /v1/org/invitations412"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).

On this page