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 |
Console ingress is separate from the API surface:
tenant console: https://console.flexgalaxy.ai
platform admin console: https://admin-console.flexgalaxy.ai
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 |
|---|---|
|
Developer can edit the registration. Not visible to tenants. |
|
Submitted for platform review; the version is frozen until the reviewer decides. Not visible to tenants. |
|
The platform reviewer’s verdict. |
|
Approved registration, provisioned and visible in the service catalog; tenants can install it. |
|
Rejected, or provisioning failed; not in the catalog. |
|
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 |
|---|---|---|
|
|
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. |
|
|
List services owned by the developer. |
|
|
Fetch the latest service registration. |
|
|
Resubmit a service registration manifest. The create call already submits for review; an already-submitted version is refused as immutable. |
|
|
Fetch the current review state — |
|
|
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 |
|---|---|---|
|
|
List published services installable by this actor. |
|
|
Inspect permissions, scopes, quotas, and terms. |
|
|
Install for a standalone Account. |
|
|
List active service installations for a standalone Account. |
|
|
Install for an Organization. |
|
|
List active service installations for an Organization. |
|
|
Poll install/provisioning status. |
|
|
Uninstall and deprovision scoped artifacts. |
PassPort runtime APIs¶
PassPort runtime APIs live under:
/passport/v1
Current implemented endpoints include:
Method |
Path |
Purpose |
|---|---|---|
|
|
Fetch registered application metadata. |
|
|
Create a launch link. |
|
|
Redeem a launch token after authentication. |
|
|
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.
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 |
|---|---|---|
|
|
Lowercase slug, globally unique in the catalog. |
|
|
Derived from the service ID; no tenant/account suffix supplied by developer. |
IAM namespace |
|
The service ID is the action namespace. |
IAM action |
|
CamelCase domain action; policy statements reference |
Audit event |
|
Service namespace plus event name. |
Quota key |
|
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
}
5. Create a PassPort launch link¶
After installation, a user can enter Acme Vision through PassPort.
Current PassPort runtime endpoint:
curl -fsS \
-X POST "$FGAI_API_BASE/passport/v1/launch-links" \
-H "Authorization: Bearer $ACCOUNT_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"appId": "acme-vision",
"accountId": "'"$ACCOUNT_ID"'",
"redirectUrl": "https://app.acme.example/",
"scopes": ["openid"],
"ttlSeconds": 900
}'
Expected response:
The launch URL is issued by PassPort, for example under https://passport.flexgalaxy.ai in production.
{
"appId": "acme-vision",
"accountId": "acc_01jz0example",
"launchToken": "lt_01jz0example",
"launchUrl": "<passport-launch-url>",
"redirectUrl": "https://app.acme.example/",
"scopes": ["openid"],
"expiresAt": "2026-06-07T12:15:00Z"
}
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:
validis false;appIddoes 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 |
|---|---|
|
Syntax-level request error outside schema validation. |
|
Missing or invalid bearer token. |
|
Authenticated actor lacks permission. |
|
Registration, service, installation, or app not found. |
|
Idempotency, version, or namespace conflict. |
|
Manifest schema or semantic validation failed. |
|
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.