DotID external developer API example

This example shows how a third-party developer integrates an application with DotID through the APaaS public API gateway.

The example application is Acme Vision, a fictional widget camera analytics product. Acme Vision wants to:

  • register itself as a third-party APaaS application;

  • expose app-specific scopes;

  • request OIDC browser login through PassPort;

  • publish IAM actions and managed permission sets;

  • publish quota definitions;

  • publish audit event taxonomy;

  • become installable by an Account or Organization administrator;

  • validate a PassPort launch ticket when a user enters the app.

This example documents the clean external developer API model. It intentionally does not teach legacy registration endpoints, client-registry, or raw Keycloak Admin APIs.

Runnable code

The runnable example lives at:

examples/external-developer-api/

It is a dependency-free Node.js client that performs its integration calls through the published external API below — developer registration, review status, service catalog, tenant installation, and PassPort app entry — using a developer OIDC credential.

The example uses the clean public capability API names:

/developer/v1/...
/service-catalog/v1/...
/accounts/v1/...
/organizations/v1/...
/passport/v1/...

Architecture

External developers never call Keycloak Admin APIs and never write DotID subsystem tables directly.

The public flow is:

Developer tool
  -> public API gateway
  -> developer API
  -> platform review
  -> service catalog publication
  -> tenant installation
  -> scoped provisioning
  -> PassPort app entry
  -> third-party application

The rejected flow is:

Developer tool
  -> Keycloak Admin API
  -> tenant realm mutation

Keycloak is an implementation dependency owned by the platform. Developers describe the integration they need; the platform approves, provisions, audits, and reconciles the resulting state.

Hostnames

Environment

Base URL

Production

https://api.flexgalaxy.ai

Console ingress is separate from the API surface:

Use the console hostnames for browser-console testing. Use the API base URL for external developer API testing.

Actor model

This example uses four actors.

Actor

Description

Developer

Builds and registers Acme Vision. Uses developer API credentials.

Platform reviewer

Platform admin who approves or rejects registrations.

Tenant admin

Account or Organization administrator who installs Acme Vision.

Application user

User who enters Acme Vision through a PassPort launch link.

Authentication model

Developer APIs use platform-issued developer credentials, not Keycloak Admin tokens.

The API contract assumes a bearer token with claims equivalent to:

{
  "iss": "<identity-issuer-url>",
  "sub": "developer/acme",
  "aud": "developer-api",
  "developer_id": "acme",
  "scope": "developer.services:write developer.services:read"
}

For production, the issuer is https://identity.flexgalaxy.ai.

Lifecycle

External registration is not self-publishing. The implemented lifecycle is:

DRAFT
  -> in review (PENDING)
  -> approved / rejected
  -> active (provisioned, installable) / failed
  -> retired (on unregister)

Meaning:

State

Meaning

DRAFT

Developer can edit the registration. Not visible to tenants.

in_review

Submitted for platform review; the version is frozen until the reviewer decides. Not visible to tenants.

approved / rejected

The platform reviewer’s verdict.

ACTIVE

Approved registration, provisioned and visible in the service catalog; tenants can install it.

FAILED

Rejected, or provisioning failed; not in the catalog.

RETIRED

Removed from the catalog by the developer after uninstall.

Creating a registration submits it for review in the same call, so a fresh registration is a draft pending review (in_review), never directly active. The review outcome is a platform-side decision — a developer cannot approve or reject their own registration. Deleting a registration retires it once no active tenant installations remain.

Tenant installation is a separate lifecycle. A registration becoming active does not install it into every Account or Organization.

A registration’s review states are draft, in_review, approved, rejected, published, and retired (see the review endpoint below). A freshly submitted registration starts as a draft pending review and becomes installable once a platform reviewer approves it.

Target API surface

Developer-facing APIs

Developer-facing APIs live under:

/developer/v1

Initial target endpoints:

Method

Path

Purpose

POST

/developer/v1/services

Create and submit a service registration manifest for platform review. The registration starts as a draft pending review and becomes installable only after a platform reviewer approves it.

GET

/developer/v1/services

List services owned by the developer.

GET

/developer/v1/services/{service_id}

Fetch the latest service registration.

POST

/developer/v1/services/{service_id}/submit

Resubmit a service registration manifest. The create call already submits for review; an already-submitted version is refused as immutable.

GET

/developer/v1/services/{service_id}/review

Fetch the current review state — draft, in_review, approved, rejected, published, or retired — and any reviewer notes.

DELETE

/developer/v1/services/{service_id}

Retire the service registration when no active tenant installations remain.

Review is a platform-side decision, not a developer API call. When a platform reviewer approves a registration, it is provisioned, becomes ACTIVE, and is listed in the service catalog where tenants can install it; a rejection ends it as FAILED. Developer clients never drive the review step for their own registrations.

Tenant installation APIs

Tenant installation APIs are exposed to Account and Organization admins:

/accounts/v1/{account_id}/service-installations
/organizations/v1/{org_id}/service-installations

Initial target endpoints:

Method

Path

Purpose

GET

/service-catalog/v1/services

List published services installable by this actor.

GET

/service-catalog/v1/services/{service_id}

Inspect permissions, scopes, quotas, and terms.

POST

/accounts/v1/{account_id}/service-installations

Install for a standalone Account.

GET

/accounts/v1/{account_id}/service-installations

List active service installations for a standalone Account.

POST

/organizations/v1/{org_id}/service-installations

Install for an Organization.

GET

/organizations/v1/{org_id}/service-installations

List active service installations for an Organization.

GET

/service-catalog/v1/installations/{installation_id}

Poll install/provisioning status.

DELETE

/service-catalog/v1/installations/{installation_id}

Uninstall and deprovision scoped artifacts.

PassPort runtime APIs

PassPort runtime APIs live under:

/passport/v1

Current implemented endpoints include:

Method

Path

Purpose

GET

/passport/v1/applications/{app_id}

Fetch registered application metadata.

POST

/passport/v1/launch-links

Create a launch link.

POST

/passport/v1/launches/redeem

Redeem a launch token after authentication.

POST

/passport/v1/launch-tickets/validate

Validate an app-bound launch ticket.

The developer registration API creates or updates the PassPort application record indirectly. Developers should not write PassPort tables directly. Smoke tests for GET /passport/v1/applications/{app_id} must provide an app id that exists in the target environment.

Policy authoring APIs

A permission set lists the policies it grants by name (policy_names in the manifest). To fill those names with real policy content, you author the policy document in your own account and reference it by name — the same shape as an AWS IAM Identity Center customer-managed policy.

Method

Path

Purpose

POST

/identity/v1/accounts/{account_id}/iam-policies

Author an account-scoped policy document in your own account.

POST

/identity/v1/permission-sets/{permission_set_id}/customer-policies

Reference one of your policies by name on a permission set (body: {"policy_name": "…"}).

DELETE

/identity/v1/permission-sets/{permission_set_id}/customer-policies/{policy_name}

Remove a by-name reference.

Authoring is account-scoped: your access-key credential is bound to one account, and authoring a policy in any other account is rejected.

References resolve per account, by name, at authorization time. For a principal acting in account A, a referenced name resolves to the policy of that name in account A. The same permission set installed in account B resolves to B’s own policy of that name (or nothing) — a permission set never carries one account’s policy content into another. Once a granted principal’s permission set resolves to a policy that allows the requested action, the principal is authorized (ALLOW).

Example service manifest

Acme Vision submits one service registration manifest.

{
  "manifestVersion": "1.0.0",
  "serviceId": "acme-vision",
  "displayName": "Acme Vision",
  "description": "Computer vision analytics for widget cameras.",
  "surfaces": {
    "iam": {
      "service_id": "acme-vision",
      "display_name": "Acme Vision",
      "actions": [
        {
          "name": "CameraView",
          "audience": "tenant-external",
          "description": "View camera streams and metadata."
        },
        {
          "name": "FrameAnalyze",
          "audience": "tenant-external",
          "description": "Submit frames for analysis."
        }
      ],
      "managed_policies": [
        {
          "name": "AcmeVisionReadOnlyAccess",
          "description": "Read camera streams and analysis results.",
          "statements": [
            {
              "Sid": "ReadCameraStreams",
              "Effect": "Allow",
              "Action": ["acme-vision:CameraView"],
              "Resource": ["frn:${account_id}:acme-vision:camera/*"]
            }
          ]
        },
        {
          "name": "AcmeVisionOperatorAccess",
          "description": "Use Acme Vision analysis features.",
          "statements": [
            {
              "Sid": "AnalyzeFrames",
              "Effect": "Allow",
              "Action": ["acme-vision:FrameAnalyze"],
              "Resource": ["frn:${account_id}:acme-vision:frame/*"]
            }
          ]
        }
      ],
      "managed_permission_sets": [
        {
          "name": "AcmeVisionViewer",
          "policy_names": ["AcmeVisionReadOnlyAccess"]
        },
        {
          "name": "AcmeVisionOperator",
          "policy_names": ["AcmeVisionOperatorAccess"]
        }
      ]
    },
    "quota": {
      "catalog": [
        {
          "key": "camera-streams",
          "display_name": "Camera streams",
          "unit": "streams",
          "scope": "account",
          "minimum_value": 0,
          "default_value": 50,
          "maximum_value": 1000,
          "adjustable": true
        },
        {
          "key": "frame-analysis-per-minute",
          "display_name": "Frame analysis requests per minute",
          "unit": "requests/minute",
          "scope": "account",
          "minimum_value": 0,
          "default_value": 120,
          "maximum_value": 10000,
          "adjustable": true
        }
      ]
    },
    "audit": {
      "events": [
        {
          "name": "acme-vision.CameraLinked",
          "category": "management",
          "severity": "medium",
          "payload_schema_ref": "https://schemas.acme.example/audit/camera-linked.v1.json"
        },
        {
          "name": "acme-vision.FrameAnalyzed",
          "category": "data",
          "severity": "low",
          "payload_schema_ref": "https://schemas.acme.example/audit/frame-analyzed.v1.json"
        }
      ]
    },
    "app_launch": {
      "launch_path": "/passport/apps/acme-vision",
      "required_action": "acmeVision:CameraView",
      "resource_frn_pattern": "frn:${account_id}:acme-vision:camera/*",
      "allowed_redirect_paths": [
        "/passport/apps/acme-vision",
        "/partners/acme-vision/dashboard"
      ],
      "installed_accounts": []
    },
    "backend_metadata": {
      "openapi_url": "https://api.acme.example/openapi.json",
      "health_url": "https://api.acme.example/health",
      "webhook_url": "https://api.acme.example/flexgalaxy/webhooks",
      "support_url": "https://acme.example/support",
      "data_classification": "tenant-confidential"
    }
  }
}

The OIDC client is not declared in surfaces.oidc: when omitted, DotID synthesizes it from your serviceId — client_id = service_id, confidential by default, realm idc — and validates the synthesized manifest against the service-registration schema. To control the client explicitly, declare an oidc surface (client_id, display_name, optional audiences).

Naming rules

Use stable, lowercase IDs for machine identifiers:

Field

Example

Rule

service_id

acme-vision

Lowercase slug, globally unique in the catalog.

client_id

acme-vision

Derived from the service ID; no tenant/account suffix supplied by developer.

IAM namespace

acme-vision

The service ID is the action namespace.

IAM action

CameraView

CamelCase domain action; policy statements reference acme-vision:CameraView.

Audit event

acme-vision.CameraLinked

Service namespace plus event name.

Quota key

camera-streams

Stable slug scoped under service ID by DotID.

The platform owns tenant-specific suffixing, realm placement, generated client IDs, installation IDs, and provisioned resource identifiers.

Walkthrough

Set a base URL:

export FGAI_API_BASE=http://api.dev.fgai.test
export DEVELOPER_TOKEN="<developer bearer token>"

For production, point FGAI_API_BASE at https://api.flexgalaxy.ai instead.

1. Create a service registration

curl -fsS \
  -X POST "$FGAI_API_BASE/developer/v1/services" \
  -H "Authorization: Bearer $DEVELOPER_TOKEN" \
  -H "Content-Type: application/json" \
  -d @acme-vision.service.json

Expected response:

{
  "service_id": "acme-vision",
  "version": "1.0.0",
  "state": "DRAFT",
  "manifest_id": "0a8f4c2e-9d31-4b7a-8e5f-1c2b3a4d5e6f",
  "links": {
    "self": "/developer/v1/services/acme-vision",
    "review": "/developer/v1/services/acme-vision/review",
    "catalog": "/service-catalog/v1/services/acme-vision"
  }
}

The create call submits the registration for platform review: the manifest stays a draft and the review state becomes in_review. Check it through the review link:

curl -fsS \
  "$FGAI_API_BASE/developer/v1/services/acme-vision/review" \
  -H "Authorization: Bearer $DEVELOPER_TOKEN"
{
  "service_id": "acme-vision",
  "version": "1.0.0",
  "state": "DRAFT",
  "manifest_id": "0a8f4c2e-9d31-4b7a-8e5f-1c2b3a4d5e6f",
  "review_status": "in_review",
  "review_notes": []
}

2. Platform review decision

Resubmitting the same version while it is in review is refused — the submitted revision is immutable:

curl -sS \
  -X POST "$FGAI_API_BASE/developer/v1/services/acme-vision/submit" \
  -H "Authorization: Bearer $DEVELOPER_TOKEN" \
  -H "Content-Type: application/json" \
  -d @acme-vision.service.json

The API returns 409 Conflict (already submitted).

The review outcome is decided by a platform reviewer on the platform review surface, not by the developer:

curl -fsS \
  -X POST "$FGAI_API_BASE/platform/dotid/service-registrations/acme-vision/decision" \
  -H "Authorization: Bearer $PLATFORM_REVIEWER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"decision": "APPROVED"}'

An approved registration is provisioned and becomes ACTIVE; a REJECTED decision ends it as FAILED. After approval the review state reads published:

curl -fsS \
  "$FGAI_API_BASE/developer/v1/services/acme-vision/review" \
  -H "Authorization: Bearer $DEVELOPER_TOKEN"
{
  "service_id": "acme-vision",
  "version": "1.0.0",
  "state": "ACTIVE",
  "manifest_id": "0a8f4c2e-9d31-4b7a-8e5f-1c2b3a4d5e6f",
  "review_status": "published",
  "review_notes": []
}

The registration is now in the catalog and installable by tenants.

3. Retire a service registration

After all tenant installations are removed, a developer can retire the service registration so it leaves the public catalog:

curl -fsS \
  -X DELETE "$FGAI_API_BASE/developer/v1/services/acme-vision" \
  -H "Authorization: Bearer $DEVELOPER_TOKEN"

Expected response:

{
  "service_id": "acme-vision",
  "version": "1.0.0",
  "state": "RETIRED",
  "manifest_id": "0a8f4c2e-9d31-4b7a-8e5f-1c2b3a4d5e6f"
}

If active installations still exist, the API returns 409 Conflict. Remove installations through DELETE /service-catalog/v1/installations/{installation_id} first.

4. Tenant installs the service

Once a published version is available in the catalog, an Account admin reviews the entry:

curl -fsS \
  "$FGAI_API_BASE/service-catalog/v1/services/acme-vision" \
  -H "Authorization: Bearer $ACCOUNT_ADMIN_TOKEN"

The response shows the catalog entry — display name, description, declared actions, quotas, and audit events — with links for review and installation.

Install into an Account:

curl -fsS \
  -X POST "$FGAI_API_BASE/accounts/v1/$ACCOUNT_ID/service-installations" \
  -H "Authorization: Bearer $ACCOUNT_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "service_id": "acme-vision"
  }'

Expected response:

{
  "installation_id": "svcinst_01jz0acmevision",
  "scope_type": "ACCOUNT",
  "scope_id": "acc_01jz0example",
  "service_id": "acme-vision",
  "version": "1.0.0",
  "status": "ACTIVE",
  "idempotent": false
}

Poll status:

curl -fsS \
  "$FGAI_API_BASE/service-catalog/v1/installations/svcinst_01jz0acmevision" \
  -H "Authorization: Bearer $ACCOUNT_ADMIN_TOKEN"

Completion:

{
  "installation_id": "svcinst_01jz0acmevision",
  "scope_type": "ACCOUNT",
  "scope_id": "acc_01jz0example",
  "service_id": "acme-vision",
  "version": "1.0.0",
  "status": "ACTIVE",
  "idempotent": false
}

6. Redeem launch and redirect to application

The browser follows the PassPort launch URL. PassPort authenticates the user if needed, validates installation and scope, then issues an app-bound launch ticket and redirects only to an allowlisted Acme Vision URL.

Conceptual redirect:

https://app.acme.example/?launch_ticket=ticket_opaque

The launch ticket is short-lived and app-bound. It is not a broad console session.

7. Application validates launch ticket

Acme Vision validates the launch ticket before creating an app session:

curl -fsS \
  -X POST "$FGAI_API_BASE/passport/v1/launch-tickets/validate" \
  -H "Content-Type: application/json" \
  -d '{
    "appId": "acme-vision",
    "launchTicket": "ticket_opaque"
  }'

Expected response:

{
  "valid": true,
  "appId": "acme-vision",
  "accountId": "acc_01jz0example",
  "displayName": "Acme Vision",
  "subject": "user_01jz0example",
  "redirectUrl": "https://app.acme.example/",
  "scopes": ["openid"],
  "expiresAt": "2026-06-07T12:16:00Z"
}

The application must reject the request if:

  • valid is false;

  • appId does not match its own app ID;

  • required scopes are missing;

  • the ticket has expired;

  • the account context is not acceptable for the app session.

Security rules

No direct Keycloak mutation

Third-party developers must not receive Keycloak Admin credentials.

The platform owns:

  • realm placement;

  • generated client IDs when tenant-specific suffixing is required;

  • redirect URI validation;

  • protocol mappers;

  • client scopes;

  • tenant-scoped provisioning;

  • disabling and cleanup.

OIDC client types

The example above registers a confidential client: DotID derives the OIDC client (client_id = acme-vision) from the request envelope and issues a client_secret (token_endpoint_auth_method: client_secret_basic). A browser or native app can instead request a public client with public_client: true — PKCE (S256) and no client_secret. Native (custom-scheme) and loopback redirects likewise require a public client; a confidential client that declares one is rejected 400, never silently downgraded to a secret-bearing client. Public clients are stamped with the aud (flexgalaxy-api) and account_id mappers automatically. See OIDC client setup for the full client-type and redirect-trust model.

HTTPS redirect URLs

Production registrations must use HTTPS redirect URLs. Local .test hostnames are allowed only for local simulation fixtures. A production https:// redirect on a public client is trusted only when its host is proven via the app’s verified domains (App Links / Universal Links) — see Redirect URIs.

No wildcard production origins

Do not approve broad redirect patterns such as:

https://*.example.com/*

Prefer exact origins and exact callback paths:

https://app.acme.example/passport/callback

App-scoped PassPort sessions

PassPort entry must not grant normal console access.

The app receives only an app-bound launch ticket or app-scoped token. The resulting application session must not be accepted by console apps.

Scope intersection

Effective launch scopes are the intersection of:

  • scopes registered by the app;

  • scopes approved by platform review;

  • scopes granted by tenant installation;

  • scopes requested by the launch link;

  • scopes allowed for the authenticated user.

If any layer removes a scope, the app does not receive it.

Idempotency

Registration and installation APIs should support idempotency keys.

Recommended header:

Idempotency-Key: acme-vision-1.0.0-submit

Repeated requests with the same key and same body should return the existing result. Repeated requests with the same key and a different body should return a conflict.

Error model

Use structured errors:

{
  "error_code": "redirect_uri_not_allowed",
  "message": "Redirect URI is not registered for this application.",
  "details": {
    "field": "surfaces.oidc.redirect_uris[1]"
  }
}

Recommended HTTP statuses:

Status

Meaning

400

Syntax-level request error outside schema validation.

401

Missing or invalid bearer token.

403

Authenticated actor lacks permission.

404

Registration, service, installation, or app not found.

409

Idempotency, version, or namespace conflict.

422

Manifest schema or semantic validation failed.

429

Rate limit exceeded.

Non-goals

This example does not cover:

  • direct Keycloak Admin API usage;

  • normal console login;

  • StartPoint account selection;

  • platform-admin UI implementation;

  • production billing or commercial terms.

See also