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/

Run it against the local API gateway simulation:

cd examples/external-developer-api
../../scripts/seed-local-example-fixtures.sh
node src/dotid-developer-api.mjs smoke

The smoke command runs the local fixture seed automatically when FGAI_API_BASE points at localhost or *.dev.fgai.test. The seed creates the deterministic account, organization, and installer group used by the account and organization install examples:

DOTID_ACCOUNT_ID=11111111-1111-1111-1111-111111111111
DOTID_ORG_ID=22222222-2222-2222-2222-222222222222
DOTID_GROUP_ID=33333333-3333-3333-3333-333333333333
DOTID_ORG_ACCOUNT_IDS=11111111-1111-1111-1111-111111111111

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

Local simulation

{{ dotid_api_local }}

Production

{{ dotid_api_prod }}

Console ingress is separate from the API surface:

  • tenant console: {{ dotid_console_local }}

  • platform admin console: {{ dotid_admin_console_local }}

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, rejects, suspends, or retires 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 {{ fgai_identity_prod }}.

Lifecycle

The service-registration lifecycle is:

DRAFT
  -> SUBMITTED
  -> APPROVED
  -> PUBLISHED
  -> DEPRECATED
  -> DISABLED

Meaning:

State

Meaning

DRAFT

Developer can edit the registration. Not visible to tenants.

SUBMITTED

Frozen for platform review. Not visible to tenants.

APPROVED

Platform has approved the version. Not yet generally installable.

PUBLISHED

Visible in the service catalog and installable.

DEPRECATED

Existing installs continue; new installs are blocked or discouraged.

DISABLED

Runtime entry and new provisioning are blocked.

Tenant installation is a separate lifecycle. Publishing a service does not install it into every Account or Organization.

Target API surface

Developer-facing APIs

Developer-facing APIs live under:

/developer/v1

Initial target endpoints:

Method

Path

Purpose

POST

/developer/v1/services

Create the service registration manifest; the current facade may publish it immediately after accepting 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

Idempotent compatibility endpoint for clients that model a separate submit step.

GET

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

Fetch derived review state; reviewer notes remain empty until the review state model exists.

DELETE

/developer/v1/services/{service_id}

Retire the service registration when no active tenant installations remain.

The current implementation publishes an accepted registration directly as ACTIVE. Treat submit and review as compatibility/read-side helpers, not evidence of a separate public review workflow.

Platform review of a submitted registration happens internally, before the service becomes installable. It is not an external developer API call — developer clients never drive the review step.

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.

{
  "service_id": "acme-vision",
  "display_name": "Acme Vision",
  "description": "Computer vision analytics for widget cameras.",
  "publisher": {
    "developer_id": "acme",
    "display_name": "Acme Robotics, Inc.",
    "support_url": "https://acme.example/support",
    "terms_url": "https://acme.example/legal/terms",
    "privacy_url": "https://acme.example/legal/privacy"
  },
  "version": "1.0.0",
  "surfaces": {
    "passport": {
      "applications": [
        {
          "app_id": "acme-vision",
          "display_name": "Acme Vision",
          "homepage_url": "https://app.acme.example/",
          "allowed_redirect_urls": [
            "https://app.acme.example/",
            "https://app.acme.example/passport/callback"
          ],
          "requested_scopes": [
            "acme-vision:Camera:View",
            "acme-vision:Frame:Analyze"
          ],
          "supported_launch_contexts": ["account", "organization"],
          "eligible_default_app": false
        }
      ]
    },
    "oidc": {
      "clients": [
        {
          "client_id": "acme-vision-web",
          "display_name": "Acme Vision Web",
          "application_type": "browser",
          "grant_types": ["authorization_code"],
          "pkce_required": true,
          "redirect_uris": [
            "https://app.acme.example/passport/callback"
          ],
          "post_logout_redirect_uris": [
            "https://app.acme.example/"
          ],
          "realm_targets": ["account", "identity-center"]
        }
      ]
    },
    "iam": {
      "namespace": "acme-vision",
      "actions": [
        {
          "name": "CameraView",
          "action": "acme-vision:Camera:View",
          "description": "View camera streams and metadata."
        },
        {
          "name": "FrameAnalyze",
          "action": "acme-vision:Frame:Analyze",
          "description": "Submit frames for analysis."
        }
      ],
      "managed_policies": [
        {
          "name": "AcmeVisionReadOnly",
          "description": "Read camera streams and analysis results.",
          "statements": [
            {
              "Effect": "Allow",
              "Action": ["acme-vision:Camera:View"],
              "Resource": "*"
            }
          ]
        },
        {
          "name": "AcmeVisionOperator",
          "description": "Use Acme Vision analysis features.",
          "statements": [
            {
              "Effect": "Allow",
              "Action": [
                "acme-vision:Camera:View",
                "acme-vision:Frame:Analyze"
              ],
              "Resource": "*"
            }
          ]
        }
      ],
      "managed_permission_sets": [
        {
          "name": "AcmeVisionViewer",
          "policy_names": ["AcmeVisionReadOnly"]
        },
        {
          "name": "AcmeVisionOperator",
          "policy_names": ["AcmeVisionOperator"]
        }
      ]
    },
    "quota": {
      "definitions": [
        {
          "quota_key": "camera-streams",
          "display_name": "Camera streams",
          "unit": "streams",
          "default_limit": 50,
          "adjustable": true,
          "scope": "account"
        },
        {
          "quota_key": "frame-analysis-per-minute",
          "display_name": "Frame analysis requests per minute",
          "unit": "requests/minute",
          "default_limit": 120,
          "adjustable": true,
          "scope": "account"
        }
      ]
    },
    "audit": {
      "events": [
        {
          "event_name": "acme-vision.CameraLinked",
          "category": "configuration",
          "severity": "medium",
          "resource_type": "camera",
          "payload_schema_ref": "https://schemas.acme.example/audit/camera-linked.v1.json"
        },
        {
          "event_name": "acme-vision.FrameAnalyzed",
          "category": "data",
          "severity": "low",
          "resource_type": "frame",
          "payload_schema_ref": "https://schemas.acme.example/audit/frame-analyzed.v1.json"
        }
      ]
    },
    "backend": {
      "openapi_url": "https://api.acme.example/openapi.json",
      "health_url": "https://api.acme.example/health",
      "webhook_url": "https://api.acme.example/flexgalaxy/webhooks",
      "data_classification": "tenant-confidential"
    }
  }
}

Naming rules

Use stable, lowercase IDs for machine identifiers:

Field

Example

Rule

service_id

acme-vision

Lowercase slug, globally unique in the catalog.

app_id

acme-vision

Lowercase slug, usually matches service ID for one-app services.

client_id

acme-vision-web

Lowercase slug, no tenant/account suffix supplied by developer.

IAM namespace

acme-vision

Same namespace used in all actions.

IAM action

acme-vision:Camera:View

Service namespace plus domain action.

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={{ dotid_api_local }}
export DEVELOPER_TOKEN="<developer bearer token>"

For production, point FGAI_API_BASE at {{ dotid_api_prod }} 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": "ACTIVE",
  "manifest_id": "srvreg_01jz0acmevision",
  "links": {
    "self": "/developer/v1/services/acme-vision",
    "review": "/developer/v1/services/acme-vision/review",
    "catalog": "/service-catalog/v1/services/acme-vision"
  }
}

2. Submit compatibility check

curl -fsS \
  -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

Expected response:

{
  "service_id": "acme-vision",
  "version": "1.0.0",
  "state": "ACTIVE",
  "idempotent": true,
  "manifest_id": "srvreg_01jz0acmevision"
}

The initial implementation uses the service-registration facade directly. The manifest is accepted idempotently by (service_id, version) and published when the facade reaches ACTIVE. submit is retained as an idempotent compatibility call for clients that already model a separate submission step. A richer draft/review state model can be added later without changing the public prefix.

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": "srvreg_01jz0acmevision"
}

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 requested scopes, clients, quotas, audit events, terms URL, support URL, and data classification.

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",
    "version": "1.0.0",
    "grant_permission_sets": ["AcmeVisionViewer"],
    "accepted_terms": true
  }'

Expected response:

{
  "installation_id": "svcinst_01jz0acmevision",
  "service_id": "acme-vision",
  "version": "1.0.0",
  "state": "PROVISIONING",
  "provisioning": {
    "iam": "PENDING",
    "oidc": "PENDING",
    "quota": "PENDING",
    "audit": "PENDING",
    "passport": "PENDING"
  }
}

Poll status:

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

Completion:

{
  "installation_id": "svcinst_01jz0acmevision",
  "service_id": "acme-vision",
  "version": "1.0.0",
  "state": "ACTIVE",
  "provisioning": {
    "iam": "READY",
    "oidc": "READY",
    "quota": "READY",
    "audit": "READY",
    "passport": "READY"
  }
}

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 '{
    "app_id": "acme-vision",
    "launch_ticket": "ticket_opaque"
  }'

Expected response:

{
  "valid": true,
  "app_id": "acme-vision",
  "subject": "user_01jz0example",
  "account_id": "acc_01jz0example",
  "organization_id": null,
  "scopes": ["acme-vision:Camera:View"],
  "expires_at": "2026-06-07T12:16:00Z"
}

The application must reject the request if:

  • valid is false;

  • app_id does not match its own app ID;

  • required scopes are missing;

  • the ticket has expired;

  • the account or organization 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 clients are public + PKCE

The acme-vision-web client above is a browser app, so DotID provisions it as a public client: it uses PKCE (S256) and receives 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.passport.applications[0].allowed_redirect_urls[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