Files
dify/cli/docs/specs/server/openapi.md

12 KiB

title
title
server — openapi

openapi

The /openapi/v1/* endpoint group: user-scoped, bearer-authed, programmatic-API surface. Hosts everything difyctl, third-party scripts, and integrations talk to.

Companion: endpoints.md (flat HTTP reference), tokens.md (storage + prefixes), device-flow.md (RFC 8628 logic), middleware.md (request pipeline).

Surface boundaries

Group Auth Role
/openapi/v1/* Bearer (dfoa_ / dfoe_) User-scoped programmatic surface — identity, sessions, device flow, workspaces, apps
/v1/* App-scoped key (app-) Service API, app-key-only
/console/api/* Browser cookie Dashboard — no bearer surface
/inner/api/* Enterprise-Api-Secret-Key header Server-to-server only

URL prefix

/openapi/v1/...

Distinct from /v1/ (service_api per-app keys), /console/api/ (browser cookie), /inner/api/ (s2s).

Versioned at the prefix level — /openapi/v2/ is the future major-version path. No mid-version breakage.

Auth model

Bearer only. Authorization: Bearer <token>.

Accepted token prefixes:

Prefix Subject Status
dfoa_ Dify account (device-flow approved from console) accepted
dfoe_ External SSO account (EE, IdP-approved) accepted (EE-gated routes)
dfp_ Personal Access Token rejected — 401 unknown_token_prefix
app-… App-scoped service_api key rejected — /openapi/v1/* routes to /v1/*

Scope model

AuthContext.scopes (frozenset on g.auth_ctx) gates routes:

Token kind Scopes
dfoa_ [full]
dfoe_ [apps:run, apps:read:permitted-external]

Scopes derive from prefix on every request; mint endpoints do not accept a scopes field. Endpoints declare required scope at registration; the check returns 403 insufficient_scope with the missing scope name in the body. full is the umbrella — it satisfies every check within accepted surface.

Scope catalog (wire format, colon:lower):

Scope Holders Grants
full dfoa_ only Superuser within the dfoa_ surface
apps:read dfoa_ only List + describe via /openapi/v1/apps*
apps:run both Run via the surface matching the holder's subject_type
apps:read:permitted-external dfoe_ only (EE) List + describe via /openapi/v1/permitted-external-apps*

Mint policy. Hard rejection at device-flow mint endpoint:

  • dfoa_ may receive [full], [apps:read], [apps:run], or combinations.
  • dfoe_ may receive only [apps:run, apps:read:permitted-external].
  • Cross-subject scope minting → 400 mint_policy_violation. CE deploys reject dfoe_ mint entirely.

full does not umbrella apps:read:permitted-external across surface — even a full-bearing dfoa_ hitting /permitted-external-apps* is rejected with 403 wrong_surface at the surface gate, before scope check runs. Surface gate is independent of scope semantics.

Endpoint surface

Identity + sessions

Method Path Auth Purpose
GET /openapi/v1/account Bearer Polymorphic by subject. Replaces /v1/me
GET /openapi/v1/account/sessions Bearer New — list user's active OAuth tokens (no current /v1/ equivalent). See §Sessions list shape
DELETE /openapi/v1/account/sessions/self Bearer Revoke session backing this request. Replaces /v1/oauth/authorizations/self
DELETE /openapi/v1/account/sessions/<id> Bearer + subject-match New — revoke specific session

GET /openapi/v1/account shape:

{
  "subject_type":         "account" | "external_sso",
  "subject_email":        "...",
  "subject_issuer":       null | "https://idp.partner.com",
  "account":              null | { "id", "email", "name" },
  "workspaces":           [{ "id", "name", "role" }],
  "default_workspace_id": null | "ws_..."
}

subject_type always present. Absent fields are explicit null / [].

Sessions list shape

GET /openapi/v1/account/sessions filters revoked_at IS NULL AND expires_at > NOW() AND token_hash IS NOT NULL — hard-expired rows must not surface as phantom devices.

Returns the canonical pagination envelope (see endpoints.md §/openapi/v1/apps — list shape); session row shape:

{
  "id":            "tok_...",
  "prefix":        "dfoa_ab2f",
  "client_id":     "difyctl",
  "device_label":  "difyctl on alice-mbp",
  "created_at":    "2026-04-20T10:00:00Z",
  "last_used_at":  "2026-04-26T08:30:00Z",
  "expires_at":    "2026-05-04T10:00:00Z"
}

Device flow (RFC 8628 protocol)

Method Path Auth Purpose
POST /openapi/v1/oauth/device/code Public + rate-limit Request device + user code
POST /openapi/v1/oauth/device/token Public + rate-limit Poll for token
GET /openapi/v1/oauth/device/lookup Public + rate-limit Validate user_code from /device page

These three are RFC 8628 protocol endpoints — intentionally unauthenticated. Rate-limits stay at current per-IP / per-device_code levels (see security.md).

Device flow (user approval)

Method Path Auth Purpose
POST /openapi/v1/oauth/device/approve Browser cookie + CSRF Approve device flow (account branch — mints dfoa_)
POST /openapi/v1/oauth/device/deny Browser cookie + CSRF Deny device flow

Cookie-authed because the user is approving from the dashboard.

Device flow (SSO branch, EE-only)

Method Path Auth Purpose
GET /openapi/v1/oauth/device/sso-initiate Public, @enterprise_only Build IdP auth URL, 302 to IdP
GET /openapi/v1/oauth/device/sso-complete Signed assertion (5-min TTL, nonce-consumed) Set device_approval_grant cookie (path-scoped to /openapi/v1/oauth/device), 302 → /device?sso_verified=1. IdP-side ACS callback URL must point here.
GET /openapi/v1/oauth/device/approval-context device_approval_grant cookie SPA reads claims (idempotent — nonce not consumed)
POST /openapi/v1/oauth/device/approve-external device_approval_grant cookie + CSRF Mint dfoe_ for External SSO subject

CE: @enterprise_only returns 404. EE: gated by entitlement.

Workspaces (dfoa_ surface)

Method Path Auth
GET /openapi/v1/workspaces Bearer (dfoa_ only — dfoe_ 403 wrong_surface)
GET /openapi/v1/workspaces/<id> Bearer + member

Apps — dfoa_ surface (CE + EE)

workspace_id required on every request. List/describe/run subject to Layer 0 (workspace membership) + Layer 1 ACL.

Method Path Auth
GET /openapi/v1/apps?workspace_id=<ws> Bearer + apps:read
GET /openapi/v1/apps/<id>/describe?workspace_id=<ws> Bearer + apps:read — canonical "what is this app". Supports ?fields=info,parameters,input_schema
POST /openapi/v1/apps/<id>/run Bearer + apps:run — server dispatches by apps.mode. See endpoints.md §OpenAPI — app

Permitted apps — dfoe_ surface (EE only)

No workspace concept — dfoe_ has no tenant_account_joins row. Tenant resolved from app. Layer 0 skipped. Layer 1 enforced (binary access-mode gate).

Method Path Auth
GET /openapi/v1/permitted-external-apps Bearer + apps:read:permitted-external
GET /openapi/v1/permitted-external-apps/<id> Bearer + apps:read:permitted-external
POST /openapi/v1/permitted-external-apps/<id>/run Bearer + apps:run

Blueprint registered only when ENTERPRISE_ENABLED=true. CE deploys return 404 (route absent, not 403).

Error model

Inherits the apierrors.Typed shape used by other groups:

{
  "code": "snake_case_code",
  "message": "human-readable",
  "hint": "optional next-action"
}
HTTP Code (sample) When
400 invalid_request Malformed body / missing required field
401 bearer_missing / bearer_invalid / bearer_expired Auth failures
403 wrong_surface Subject_type hit a surface reserved for the other subject_type (dfoa_/permitted-external-apps*, dfoe_/apps* or /workspaces*). Surface gate at request-flow step 6
403 insufficient_scope (with required_scope) Scope gate failed within accepted surface
403 license_required EE surface (/permitted-external-apps*, internal-mode internal-API) reached but EE license absent or expired. CE deploys never emit this
404 not_found Resource not found OR route doesn't exist on this group (e.g. /permitted-external-apps* on CE)
429 rate_limited (with retry_after_ms) Per-IP / per-token throttle
503 bearer_auth_disabled ENABLE_OAUTH_BEARER=false

CORS posture

Distinct from service_api (which is permissive for embedded use). /openapi/v1/* allows:

  • Authorization, Content-Type, X-CSRF-Token request headers
  • GET POST PATCH DELETE OPTIONS methods
  • * origin only when ENABLE_OAUTH_BEARER=true AND OPENAPI_CORS_ALLOW_ORIGINS=*; otherwise an explicit allowlist
  • Access-Control-Max-Age: 600

Cookie-authed routes within the group (approve / deny / approve-external) require same-origin and reject cross-origin OPTIONS.

Rate limit posture

  • Public device-flow endpoints: per-IP token bucket (existing settings preserved)
  • Bearer-authed routes: per-token bucket, default 60 req/min, configurable via OPENAPI_RATE_LIMIT_PER_TOKEN. Shared Redis bucket per sha256(token) across all api instances. Details: middleware.md §Rate limit
  • 429 response includes Retry-After header + retry_after_ms in body

Relationship to other groups

Group State
service_api/ (/v1/*) App-scoped keys only. service_api/oauth.py deleted — /v1/me, /v1/oauth/... retired
console/api/* Cookie-authed dashboard only. console/auth/oauth_device.py deleted
inner_api/ Unchanged — internal s2s
controllers/oauth_device_sso.py (root file) Deleted — content lives in controllers/openapi/oauth_device_sso.py
controllers/fastopenapi.py Unrelated — exports the fastopenapi library's FlaskRouter for console-side schema generation. Naming collision is cosmetic; file stays.

Gateway routing

Every gateway in front of api:5001 must route /openapi/* to it; without a rule, requests fall through to the web frontend and 404.

Deployment File Rule
dify docker-compose docker/nginx/conf.d/default.conf.template location /openapi { proxy_pass http://api:5001; include proxy.conf; }
dify-enterprise gateway server/hack/configs/gateway/Caddyfile handle /openapi/* { reverse_proxy http://api:5001 } inside console.dify.local only — cookie-authed routes are scoped to that host
dify-helm chart charts/dify/templates/gateway/caddy-config.yaml Same Caddy handle /openapi/* { reverse_proxy {{ $apiSvc }} } inside both consoleApiDomain blocks (the chart has two variants depending on whether console-api and console-web share a domain)

/openapi/* is intentionally absent from enterprise.dify.local, app.dify.local, serviceApiDomain, and api.dify.local: cookie-authed routes (approve / deny / approval-context / sso-complete) only work on the host that mints the console session cookie, and the IdP-side ACS callback pins a single hostname.

Out of scope

  • Admin / billing / setup / init endpoints — owner-only, browser ctx, stay in console
  • Plugin marketplace, tool providers — extension management
  • Webhook triggers — already separate blueprint
  • App-key features (/v1/chat-messages etc.) — stay in service_api