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

21 KiB

title
title
server — endpoints

endpoints

Flat reference of every HTTP endpoint under /openapi/v1/* and adjacent surfaces.

Companion: middleware.md (auth behavior), tokens.md (storage), device-flow.md (flow logic), security.md (rate limits + audit).

Regions

Region URL prefix Host Auth style
Console API (cookie) /console/api/* API Service (Flask) Browser console session cookie. No bearer surface
OpenAPI (user-scoped programmatic) /openapi/v1/* API Service (Flask) User-level bearer (dfoa_ / dfoe_). dfp_ rejected. Hosts device-flow protocol + approval, identity reads, session management, workspace reads
Service API /v1/* API Service (Flask) App-scoped key (app-…) only. User-level bearers go to /openapi/v1/*
Enterprise Inner API Internal Go service Enterprise Go Server-to-server only; never called by clients

The full /openapi/v1/* surface lives in openapi.md; this file is the flat HTTP reference. Gateway routing (nginx in dify, Caddy in dify-enterprise, dify-helm chart) all proxy /openapi/* to the api backend.

Personal Access Tokens

Not supported. dfp_ prefix on /openapi/v1/* returns 401 unknown_token_prefix. No /console/api/personal-access-tokens surface, no personal_access_tokens table. See tokens.md §Wire format.

OAuth device flow — account branch

Approve / deny live under /openapi/v1/* and authenticate with the console session cookie (the user clicks Authorize from the dashboard). Same handler classes; only the URL prefix differs from cookie-only console routes.

Method Path Auth Purpose
GET /openapi/v1/oauth/device/lookup Public + rate-limit Validate entered user_code. Returns { valid, expires_in_remaining, client_id }
POST /openapi/v1/oauth/device/approve Console session + CSRF Approve device flow (account branch — mints dfoa_). Body: { user_code }
POST /openapi/v1/oauth/device/deny Console session + CSRF Deny device flow. Body: { user_code }

OAuth-token management lives under /openapi/v1/account/sessions (see §Identity + sessions). Token inventory is bearer-authed via OpenAPI — no /console/api/oauth/authorizations* surface.

OAuth device flow — SSO branch (@enterprise_only)

All four endpoints gated by @enterprise_only — CE returns 404. The IdP-side ACS callback URL is the canonical sso-complete path below; reconfigure each configured IdP to point at this URL.

Method Path Auth Purpose
GET /openapi/v1/oauth/device/sso-initiate Unauthenticated Build IdP auth URL via Enterprise initiate. Query ?user_code=<required>. 302s to IdP
GET /openapi/v1/oauth/device/sso-complete Signed external-subject assertion (5-min TTL, nonce-consumed) Consume assertion, set device_approval_grant cookie (path-scoped to /openapi/v1/oauth/device), 302 → /device?sso_verified=1
GET /openapi/v1/oauth/device/approval-context device_approval_grant cookie SPA reads session claims. Returns { subject_email, subject_issuer, user_code, csrf_token, expires_at }. Idempotent — nonce not consumed
POST /openapi/v1/oauth/device/approve-external device_approval_grant cookie + X-CSRF-Token Approve device_code as External SSO subject. Body { user_code } must match cookie claim. Mints dfoe_

Decorator order: @enterprise_only → @rate_limit → handler.

OpenAPI — RFC 8628 protocol (unauthenticated / bearer)

Method Path Auth Purpose
POST /openapi/v1/oauth/device/code Public + rate-limit Device flow: request code. Body { client_id, device_label }. Returns { device_code, user_code, verification_uri, expires_in, interval }
POST /openapi/v1/oauth/device/token Public + rate-limit Device flow: poll. Body { device_code, client_id }. Per RFC 8628 error codes (authorization_pending, slow_down, expired_token, access_denied)

OpenAPI — identity + sessions (bearer)

Method Path Auth Purpose
GET /openapi/v1/account Bearer (any user-level) Polymorphic by subject. Used by post-device-flow validation + auth status -v refresh
GET /openapi/v1/account/sessions Bearer List user's active OAuth tokens. Filters revoked_at IS NULL AND expires_at > NOW() AND token_hash IS NOT NULL. Used by auth devices list
DELETE /openapi/v1/account/sessions/self Bearer Revoke session backing this request. Used by auth logout
DELETE /openapi/v1/account/sessions/<id> Bearer + subject-match Revoke specific session by id. See tokens.md §Subject-match on revoke-by-id. Used by auth devices revoke

GET /openapi/v1/account response

Account subject:

{
  "subject_type":         "account",
  "subject_email":        "user@example.com",
  "account":              { "id": "acc_...", "email": "user@example.com", "name": "..." },
  "workspaces":           [{ "id": "ws_...", "name": "...", "role": "owner" }],
  "default_workspace_id": "ws_..."
}

External SSO subject (EE):

{
  "subject_type":         "external_sso",
  "subject_email":        "sso-user@partner.com",
  "subject_issuer":       "https://idp.partner.com",
  "account":              null,
  "workspaces":           [],
  "default_workspace_id": null
}

subject_type always present. Absent fields are explicit null / [], not omitted — strict-schema agents don't fail.

OpenAPI — workspaces (bearer)

Method Path Auth Purpose
GET /openapi/v1/workspaces Bearer List user's workspaces. External SSO subjects (no account) get []
GET /openapi/v1/workspaces/<id> Bearer + member Workspace details. Non-member returns 404 (not 403 — avoids cross-tenant id leak)

OpenAPI — app (two surfaces, strict subject_type separation)

Bearer auth via dfoa_ / dfoe_. App is in the URL path — no X-Dify-App-Id header. Surface gate (@accept_subjects(...)) rejects wrong subject_type before scope check.

dfoa_ surface — /openapi/v1/apps* (CE + EE)

Method Path Scope Deployment Request / Response
GET /openapi/v1/apps?workspace_id=<ws> apps:read CE + EE AppListQueryAppPagination. Params: workspace_id (required), page, limit, mode, name, tag. List filtered through _apply_openapi_gate + AclStrategy (CE: workspace membership only; EE: access_mode allowlist + inner-API for internal)
GET /openapi/v1/apps/<app_id>/describe?workspace_id=<ws> apps:read CE + EE AppDescribeQueryAppDescribeResponse. Canonical "what is this app". Slim subset via ?fields=info. See §/describe shape
POST /openapi/v1/apps/<app_id>/run apps:run CE + EE AppRunRequestAppRunResponse (or SSE). Mode-agnostic — server dispatches on apps.mode. See §/run shape

Surface: dfoa_ only. dfoe_ → 403 wrong_surface before Layer 0. workspace_id missing on list/describe → 422 workspace_id_required.

dfoe_ surface — /openapi/v1/permitted-external-apps* (EE only)

Method Path Scope Deployment Request / Response
GET /openapi/v1/permitted-external-apps apps:read:permitted-external EE only AppPermittedListQueryAppPagination. Params: page, limit, mode, name. Strict validator (extra='forbid') — workspace_id, tag → 422. List filtered through _apply_openapi_gate + access-mode allowlist {public, sso_verified}
GET /openapi/v1/permitted-external-apps/<app_id> apps:read:permitted-external EE only Single-app metadata. Same visibility filter as list. 404 if app not in dfoe_'s permitted set
POST /openapi/v1/permitted-external-apps/<app_id>/run apps:run EE only Same AppRunRequest shape as dfoa_. Tenant resolved from app row. No workspace_id in body

Surface: dfoe_ only. dfoa_ → 403 wrong_surface. Blueprint registered only when ENTERPRISE_ENABLED=true; on CE, routes return 404 (absent — no blueprint).

Pipeline

Run + describe routes attach via @OAUTH_BEARER_PIPELINE.guard(scope=...). Pipeline: BearerCheck → ScopeCheck → SurfaceGate → AppResolver → WorkspaceMembershipCheck (dfoa_ only) → AppAuthzCheck → CallerMount. Per-token rate limit is enforced inside BearerAuthenticator.authenticate (called by BearerCheck). Server-side dispatch on apps.mode happens after AppResolver:

mode == chat | agent-chat | advanced-chat → existing chat-messages handler
mode == completion                        → existing completion-messages handler
mode == workflow                          → existing workflows/run handler

List routes attach @validate_bearer + @accept_subjects + @require_scope. The _apply_openapi_gate helper in api/services/openapi/visibility.py is the single source for the enable_api=true filter — removing it retires the gate. See middleware.md §Universal openapi gate.

Subject capability matrix

Surface dfoa_ dfoe_
GET /apps scope + Layer-0 membership; workspace_id required 403 wrong_surface
GET /apps/<id>/describe scope + Layer-0 + Layer-1 ACL 403 wrong_surface
POST /apps/<id>/run scope + Layer-0 + Layer-1 ACL 403 wrong_surface
GET /permitted-external-apps 403 wrong_surface (even with full) scope + access-mode filter (EE only)
GET /permitted-external-apps/<id> 403 wrong_surface scope + access-mode filter (EE only)
POST /permitted-external-apps/<id>/run 403 wrong_surface scope + Layer-1 binary access-mode gate (EE only)

Visibility rules per surface

dfoa_ list (GET /apps):

1. Workspace membership check (caller ∈ tenant_account_joins for workspace_id) — else 403
2. base_query = apps WHERE workspace_id = W
3. _apply_openapi_gate(base_query)               -- enforces enable_api=true
4. on CE: return query                            -- no ACL filter
5. on EE:
     visible      = query WHERE access_mode IN {public, internal_all, sso_verified}
     internal_set = query WHERE access_mode = 'internal'
     permitted    = inner_api.batch_check(caller, internal_set.ids)
     return visible UNION permitted

dfoe_ list (GET /permitted-external-apps, EE only):

1. base_query = apps WHERE access_mode IN {public, sso_verified}
2. _apply_openapi_gate(base_query)               -- enforces enable_api=true
3. return query

No workspace check, no inner-API. Cross-tenant by design — dfoe_ is a global SSO identity, not a tenant resident.

License gate

EE-specific surface (/permitted-external-apps*) consults existing console/api license helper at request-handling time. License absent / expired → 402 license_required. CE deploys do not register the surface and never emit license_required. No new env var; reuses existing ENTERPRISE_API_URL + license module.

AppRunRequest shape (mode-agnostic):

{
  "inputs":               { "key": "value" },
  "query":                "user message (chat / agent-chat / advanced-chat only — workflow rejects)",
  "files":                [ /* optional file refs */ ],
  "response_mode":        "blocking" | "streaming",
  "conversation_id":      "conv_abc (chat-family only)",
  "auto_generate_name":   false,
  "workflow_id":          "wf_abc (workflow mode only)",
  "workspace_id":         "ws_abc (informational; audit + future env routing)"
}

Server pops the caller subject from auth context — clients do not send a user field. Server validates per-mode constraints: query required for chat-family + rejected for workflow; inputs required for workflow; conversation_id ignored outside chat-family. Invalid mode/field combo → 422.

AppRunResponse shape: matches the existing per-mode response of the handler the server dispatched to (ChatMessageResponse / CompletionMessageResponse / WorkflowRunResponse). CLI renders per-mode using the mode echoed from the response envelope.

/describe shape:

{
  "info":         { "id", "name", "mode", "description", "tags", "author", "updated_at", "service_api_enabled" },
  "parameters":   { "opening_statement", "suggested_questions", "user_input_form", "file_upload", "system_parameters" },
  "input_schema": { "type": "object", "properties": { ... }, "required": [ ... ] }
}

Canonical "what is this app" surface. Consolidates info + parameters + agent-friendly JSON Schema in one round-trip. parameters carries Dify-native user_input_form (semantic labels, render hints) for human/CLI rendering. input_schema is JSON Schema (Draft 2020-12) derived server-side from user_input_form + mode-specific top-level fields (query for chat-family, inputs for workflow), agent-consumed for tool-call payload generation. All sub-objects always present; absent fields are explicit null / [].

AppDescribeQuery — query params:

Param Type Default Behaviour
fields comma-separated string omit = all Allow-list: info, parameters, input_schema. Unknown member → 422. Empty/omitted returns full payload
workspace_id UUID required on /apps/<id>/describe (dfoa_ surface) Surface gate enforces dfoa_ subject; Layer 0 needs workspace_id for membership check. Missing → 422 workspace_id_required. Not accepted on /permitted-external-apps/<id> (strict validator rejects)

Strict validator (extra='forbid'). Server skips computation for unrequested blocks: parameters_payload(app) runs only if parameters or input_schema requested; app_info_payload(app) only if info requested.

Slim variants:

Call Returns
GET /apps/<id>/describe Full {info, parameters, input_schema}
GET /apps/<id>/describe?fields=info {info} only — replaces former /info
GET /apps/<id>/describe?fields=info,parameters Subset, no input_schema derivation

CLI default fetch is full (single cache entry, 1h TTL — see apps.md); slim variant exists for forward-compat external consumers and as a ?fields=info quick-lookup path.

Pagination envelope (/apps, /permitted-external-apps, /account/sessions):

{
  "page":     1,
  "limit":    20,
  "total":    42,
  "has_more": true,
  "data":     [ /* row objects, type-specific */ ]
}

data is the literal field name on these routes. GET /openapi/v1/workspaces is the exception: it returns {"workspaces": [...]} — no pagination, no envelope. Clients should treat the workspace list as unpaginated until that route migrates.

GET /openapi/v1/apps data row:

{
  "id":              "app-abc",
  "name":            "Support Bot",
  "description":     "...",
  "mode":            "chat",
  "tags":            [{ "name": "prod" }],
  "updated_at":      "2026-04-27T10:00:00Z",
  "created_by_name": "gareth@dify.ai",
  "workspace_id":    "ws-xyz",
  "workspace_name":  "Acme Inc."
}

workspace_id + workspace_name populated on /apps rows (drives difyctl get apps -A cross-workspace fan-out cosmetics). tag query param resolved by name within target workspace; no-match = empty data (not 400).

GET /openapi/v1/permitted-external-apps — response shape:

Same PaginationEnvelope shape as /apps. tags always [] on /permitted-external-apps rows — tags are tenant-scoped and dfoe_ is cross-tenant. created_by_name always null — author identity not part of the externally-visible surface. workspace_id / workspace_name omitted — dfoe_ has no workspace concept.

Query params: page, limit, mode, name. Strict validator (extra='forbid') — workspace_id, tag, or any unknown param → 422.

Blueprint registered only when ENTERPRISE_ENABLED=true. CE → 404 (route absent). Implementation calls Enterprise inner-API POST /inner/api/webapp/externally-accessible-apps (see §Inner APIs); on 5xx the route returns 503 fail-closed. License absent → 402 license_required.

Snapshot semantics. The total count reflects the EE-side cached list at request time. Access-mode mutations between auto-paginated calls can shift total; the CLI should treat has_more=false as authoritative and not assume total is monotonic across pages. EE invalidates its cache on every access-mode write and falls back to a 10-minute TTL safety net.

Service-API /v1/parameters — unchanged.

The legacy app-key surface at GET /v1/parameters (handled by service_api/app/app.py:ParametersApi) keeps the existing Parameters shape (opening_statement, suggested_questions, user_input_form, file_upload, system_parameters). User-bearer callers use /openapi/v1/apps/<id>/describe instead.

Service API — run-slice (app-scoped key)

/v1/* is now app-scoped-key only. Subject to Service API toggle on apps.service_api_enabled (see middleware.md §Service API toggle). User-level bearers (dfoa_ / dfoe_) hit POST /openapi/v1/apps/<id>/run (see §OpenAPI — app).

Method Path Auth Purpose
POST /v1/chat-messages App key Chat apps
POST /v1/completion-messages App key Completion apps
POST /v1/workflows/run App key Workflow apps
Existing /v1/files/upload, /v1/meta, /v1/parameters, /v1/info App key Existing app-key surface

Request headers (bearer on /openapi/v1/* + /v1/* app-key)

Header Required Purpose
Authorization: Bearer <dfoa_… / dfoe_… / app-…> yes Identifies subject. app-… only on /v1/*; dfoa_ / dfoe_ only on /openapi/v1/*. dfp_ rejected
X-Dify-Env: <env-name> reserved CLI may send; server accepts + ignores
X-Dify-Workspace-Id: <uuid> reserved Accepts + ignores
User-Agent: difyctl/<semver> (<platform>; <arch>; <channel>) yes Attribution in access logs

Reserved headers. X-Dify-Env and X-Dify-Workspace-Id are accepted (no 400) and ignored. App id travels in the URL path — no X-Dify-App-Id header.

/console/api/* bearer

Not supported. /console/api/* stays cookie-only. User-scoped programmatic features live under /openapi/v1/* — see openapi.md.

Inner APIs

All /inner/api/* endpoints authenticate via Enterprise-Api-Secret-Key header (= INNER_API_KEY env on the receiving end). Failure shape is the simple {"error": "..."} form, NOT the user-facing { code, message, hint } envelope — inner APIs are gateway/s2s-internal.

Hosted by enterprise svc (gateway / api → EE)

Method Path Caller Purpose
GET /inner/api/webapp/permission?appId=<id>&userId=<account_id> dify api middleware Layer-1 ACL check for account subjects on internal access mode. Returns { result: bool }. Not called for other modes
POST /inner/api/webapp/permission/batch dify api /apps list handler Batch variant of the above. Body { user_id, app_ids: [...] }{ permitted: [app_ids] }. Used by list-time visibility filter on EE so a workspace scan with many internal apps is one round-trip
POST /inner/api/webapp/externally-accessible-apps dify api /permitted-external-apps handler Deployment-wide list of apps with access_mode{public, sso_verified}. Body { page, limit, mode?, name? }{ data: [{ app_id, tenant_id, mode, name, updated_at }], total, has_more }. No subject in the request — same result for every caller. EE caches the merged list under a single Redis key with explicit invalidation on every access-mode write + 10-min TTL safety net. Fail-closed: dify-api translates 5xx to 503 permitted_external_apps_unavailable
POST /inner/api/rbac/check-access EE gateway (dify_rbac Caddy module) Workspace-role RBAC check. Body { account_id, tenant_id, scene, resource_type, resource_id }{ allowed, reason, ... }
POST /inner/api/auth/check-access-oauth EE gateway (dify_rbac Caddy module) Token resolve for EE gateway. Body { token }{ account_id, tenant_id, subject_type, client_id, scope, expires_at[, subject_email, subject_issuer] }. New endpoint mirroring RBAC check-access pattern. See gateway.md §Inner API — auth check-access (OAuth)

Hosted by dify api (EE → api)

Method Path Caller Purpose
GET /inner/api/policy/oauth-ttl?tenantId=<id> enterprise svc Return { ttl_days: <int> } for tenant. dify api Redis-caches 60 s
POST /inner/api/enterprise/apps/batch-metadata EE WebAppUsecase.ListExternallyAccessibleApps Hydrate app metadata for a batch of app ids. Body { ids: [<= 500] }{ data: [{ id, tenant_id, mode, name, updated_at }] }. Filters out non-status=normal apps server-side. Used to merge EE-side web_app_settings rows with api-side apps columns before caching

Fallback when EE unreachable or CE deployment: env var OAUTH_TTL_DAYS → hardcoded 14.

Request flow summary

See middleware.md §Request flow for the full pipeline applied to all /v1/* endpoints.