--- title: "Error responses and error codes" description: "The refusal envelope every FlowDrop API door returns, and the full error_code vocabulary the Drupal server publishes." source: https://flowdrop.io/docs/concepts/api-errors site: FlowDrop documentation --- # Error responses and error codes {/* Claims on this page were read from apps/fddo (branch feature/26-stable-closeout): src/Controller/Api/ApiResponseTrait.php — errorResponse(), decodeJsonRequest(), the query-parameter reader src/Controller/Api/ApiErrorCode.php — the shared vocabulary and its docblocks src/Exception/RefusalCodeInterface.php — how a thrown refusal carries its code src/Exception/ApiBadRequestException.php — INVALID_REQUEST_BODY / INVALID_QUERY_PARAMETER src/EventSubscriber/FlowDropApiExceptionSubscriber.php — the thrown-refusal envelope and status mapping modules/flowdrop_interrupt/src/Api/InterruptErrorCode.php modules/flowdrop_playground/src/Api/PlaygroundErrorCode.php modules/flowdrop_session/src/Api/SessionErrorCode.php modules/flowdrop_trigger/src/Api/TriggerErrorCode.php modules/flowdrop_workflow/src/Api/WorkflowErrorCode.php modules/flowdrop_session/src/Exception/{ConcurrentTurn,MissingWorkflow,InvalidTurnInput}Exception.php modules/flowdrop_workflow_executor/src/Exception/InvalidLaunchInputException.php modules/flowdrop_interrupt/src/Exception/Interrupt{ResolutionInProgress,NotPending,Expired}Exception.php Controllers that emit each code: WorkflowsController, InterruptApiController, PipelineSignalController, PipelineApiController, SnapshotApiController, SessionTurnApiController, WorkflowLaunchApiController, TriggerConfigApiController, ChatApiController, PlaygroundApiController docs/development/specification-registry.md rows API-8 and INT-16 CHANGELOG.md [Unreleased] and [2.6.0-alpha3] */} When a FlowDrop API door refuses a request, it answers a JSON envelope carrying two things: a human-readable `error` and a machine-readable `error_code`. ```json { "success": false, "error": "The workflow changed on the server since it was loaded; reload before saving", "error_code": "CONFLICT" } ``` ## Branch on the code, not the message `error` is prose. It describes the refusal for a human reader and it may be reworded at any time. `error_code` is the contract: once published, a code's meaning never changes and it is never re-pointed at a different refusal. A client that classifies a refusal by matching substrings of `error` breaks silently the first time the wording changes — the match fails, the refusal is reclassified, and nothing errors. Compare `error_code` with `===` instead. Adding a code is additive; renaming one is a break, and there is no deprecation path for a string clients compare exactly. A new condition gets a new code rather than widening an existing one. A code names a **condition**, never a door or a route: - It does not identify the resource — the route already does. `NOT_FOUND` on `/workflows/{id}` needs no `WORKFLOW_NOT_FOUND` spelling. - It carries no field locator. That is what a 422's `details[]` is for. ## Validation refusals carry `details[]` A payload that decoded and then failed the rules answers **422** with `VALIDATION_FAILED` in the envelope and one entry per cause in `details`: ```json { "success": false, "error": "Workflow validation failed", "error_code": "VALIDATION_FAILED", "details": [ { "code": "R14_EDGE_TYPE_UNKNOWN", "message": "Edge at position 2 declares edgeType \"loopbak\", which is not a recognised edge type. Use one of: data, trigger, tool_availability, loopback, agent_result.", "locator": "edge.2" } ] } ``` The envelope code says which stage refused; `details[].code` says what about the payload was wrong. The two vocabularies are separate and spelled differently — the envelope uses screaming snake case, while `details[].code` carries whatever the rule that produced it publishes (`R14_EDGE_TYPE_UNKNOWN` from the workflow validator, `version_mismatch` from a stored-definition check, `BUNDLE_FLOW_ID_INVALID` from the bundle importer). Read `details[].code` to identify a cause; do not expect it to appear in the table below. ## Thrown refusals use a different envelope Some refusals never reach a controller at all — a permission denial, an unmatched path, a failed parameter conversion. Those are converted centrally, and the body they produce carries `error`, `message` and `error_code`: ```json { "error": "Access denied", "message": "The requested resource does not exist.", "error_code": "ACCESS_DENIED" } ``` Here `error` is a generic label and `message` is the sentence worth reading. `error_code` means exactly what it means everywhere else, so a client that branches on it needs no special case. ## When `error_code` is absent The key is emitted only when a reason is known, and omission is deliberate — a fabricated code would be worse than none: - **A thrown 400 that names no reason.** A 403 and a 404 mean one thing each and are mapped from the status alone. A 400 covers an empty body, an oversized body, undecodable JSON, a body that is not an object and a query parameter of the wrong type — five refusals with one status — so a 400 whose exception does not carry its own code carries no `error_code`. The refusals raised by the shared JSON decoder and the shared query-parameter reader *do* carry one, and every door that catches them relays it. - **Refusals the workflow and trigger doors `throw`** rather than return. Those are still being migrated. - **The pipeline job door**, which builds its own envelope and does not use the shared one. Treat a missing `error_code` as "unclassified refusal" and fall back to the HTTP status — never to the message text. ## The vocabulary | Code | HTTP | Emitted by | Means | | --- | --- | --- | --- | | `NOT_FOUND` | 404 | Every door; also every thrown 404 on the API surface | The addressed resource does not exist, or is not visible to the caller. A door that hides existence from an unauthorised caller answers this rather than `ACCESS_DENIED`. | | `ACCESS_DENIED` | 403 | Chat, interrupts, pipelines, snapshots; also every thrown 403 | The resource exists and the caller may not act on it. | | `INVALID_REQUEST_BODY` | 400 | The shared JSON decoder, relayed by session turns, workflow launch, chat and the Playground; also returned directly by interrupts and the Playground | The body is empty, oversized, undecodable, not a JSON object, or missing a required field. It never reached a state worth validating. | | `INVALID_QUERY_PARAMETER` | 400 | The shared query-parameter reader; workflow listing | A query parameter is malformed or carries the wrong type — an array where a single value is declared. Separate from `INVALID_REQUEST_BODY` because you fix it somewhere else. | | `VALIDATION_FAILED` | 422 | Workflow save, workflow validate, bundle import | The payload decoded and was refused by the rules that govern it. Always alongside `details[]`. The chat door also publishes it on 400 body-shape refusals. | | `INVALID_INPUTS` | 400 | Session turns, workflow launch | The supplied `inputs` do not match the workflow's declared manifest: an undeclared key, a missing required one, or a value that violates the published schema. | | `RATE_LIMITED` | 429 | Chat | The caller is over a flood-control rate limit. Wait and retry. | | `INTERNAL_ERROR` | 500 | Every door | An unexpected failure. Deliberately opaque — the cause is logged, not published. A refusal you could have avoided is never this code. | | `CONFLICT` | 409 | Workflow save | The workflow changed on the server since it was loaded. Sent only when the client returns a `revision` stamp with the save; the body adds `details` naming the `current` and `sent` stamps. Reload before saving. | | `WORKFLOW_ID_CONFLICT` | 409 | Workflow create, bundle import | The client-supplied workflow id is already taken. Pick another id, or update the existing workflow. | | `BUNDLE_FORMAT_UNSUPPORTED` | 422 | Bundle import | The uploaded bundle is not in a format this door reads. Nothing was validated, because nothing could be parsed as a bundle. | | `BUNDLE_PUBLISHER_UNTRUSTED` | 403 | Bundle import | The bundle's publisher is not trusted. About the signer, not the caller: trust the publisher, or resend with `confirm_untrusted` if you hold the permission. The refusal carries `signature` and `hint`. | | `BUNDLE_CAPABILITY_MISSING` | 422 | Bundle import | Processors the bundle needs are not installed here. A property of this site, not of the bundle. The refusal carries the `manifest` naming what is missing. | | `PIPELINE_TERMINAL` | 409 | Pipeline cancel, pause, resume | The pipeline has already finished. | | `INWARD_SIGNAL_ALREADY_PENDING` | 409 | Pipeline cancel, pause | An inward signal is already pending on this pipeline — a duplicate request. | | `NO_ACTIVE_PAUSE` | 409 | Pipeline resume | There is no pause to resume. | | `CALLBACK_WRONG_TYPE` | 409 | Interrupt callback | The callback route only resolves `external_call` rows; every other interrupt type is a prompt meant for a human. | | `CALLBACK_WRONG_DIRECTION` | 409 | Interrupt callback | The callback route only resolves outward interrupts. An inward-stamped `external_call` is an operator signal, not something a remote system was invited to answer. | | `NOT_CANCELLABLE` | 409 | Interrupt cancel | The interrupt is not in a cancellable (pending) status — nothing to cancel. | | `RESOLUTION_IN_PROGRESS` | 409 | Interrupt resolve, interrupt callback, pipeline resume | Another resolution of this interrupt holds the lock. Transient: the same request succeeds once the holder finishes. | | `INTERRUPT_NOT_PENDING` | 409 | Interrupt resolve, interrupt callback, pipeline resume | The interrupt has already been settled — resolved, cancelled or expired. An interrupt is answerable exactly once. | | `INTERRUPT_EXPIRED` | 409 | Interrupt resolve, interrupt callback, pipeline resume | The interrupt's deadline passed before the answer came. Nobody answered in time, rather than somebody already did. | | `TURN_IN_PROGRESS` | 409 | Session turn | The session is already running a turn. Wait for `session.turn.completed` and retry. | | `NO_ASSOCIATED_WORKFLOW` | 409 | Session turn | The session has no workflow to run a turn against. Attach one first. | | `EXECUTION_NOT_RUNNING` | 409 | Playground stop | There is no running execution to stop. | | `NOT_A_CRON_TRIGGER` | 400 | Trigger history, trigger reset-state | The endpoint only applies to cron-family triggers. | | `CRON_STATE_UNAVAILABLE` | 503 | Trigger history, trigger reset-state | The optional cron state service is not configured. The request and the trigger are both fine; the site has no state backend to answer from. | ## Next steps - [Edge structure](/concepts/edge-json) — including the save-time validation code you will meet in `details[]`. - [API reference](/api) — the endpoints themselves.