Error responses and error codes
The refusal envelope every FlowDrop API door returns, and the full error_code vocabulary the Drupal server publishes.
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.
{
"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_FOUNDon/workflows/{id}needs noWORKFLOW_NOT_FOUNDspelling. - 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:
{
"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:
{
"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
throwrather 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 — including the save-time validation code you will meet in
details[]. - API reference — the endpoints themselves.