FlowDrop Docs

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_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:

{
  "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 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

CodeHTTPEmitted byMeans
NOT_FOUND404Every door; also every thrown 404 on the API surfaceThe 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_DENIED403Chat, interrupts, pipelines, snapshots; also every thrown 403The resource exists and the caller may not act on it.
INVALID_REQUEST_BODY400The shared JSON decoder, relayed by session turns, workflow launch, chat and the Playground; also returned directly by interrupts and the PlaygroundThe body is empty, oversized, undecodable, not a JSON object, or missing a required field. It never reached a state worth validating.
INVALID_QUERY_PARAMETER400The shared query-parameter reader; workflow listingA 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_FAILED422Workflow save, workflow validate, bundle importThe 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_INPUTS400Session turns, workflow launchThe 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_LIMITED429ChatThe caller is over a flood-control rate limit. Wait and retry.
INTERNAL_ERROR500Every doorAn unexpected failure. Deliberately opaque — the cause is logged, not published. A refusal you could have avoided is never this code.
CONFLICT409Workflow saveThe 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_CONFLICT409Workflow create, bundle importThe client-supplied workflow id is already taken. Pick another id, or update the existing workflow.
BUNDLE_FORMAT_UNSUPPORTED422Bundle importThe uploaded bundle is not in a format this door reads. Nothing was validated, because nothing could be parsed as a bundle.
BUNDLE_PUBLISHER_UNTRUSTED403Bundle importThe 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_MISSING422Bundle importProcessors 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_TERMINAL409Pipeline cancel, pause, resumeThe pipeline has already finished.
INWARD_SIGNAL_ALREADY_PENDING409Pipeline cancel, pauseAn inward signal is already pending on this pipeline — a duplicate request.
NO_ACTIVE_PAUSE409Pipeline resumeThere is no pause to resume.
CALLBACK_WRONG_TYPE409Interrupt callbackThe callback route only resolves external_call rows; every other interrupt type is a prompt meant for a human.
CALLBACK_WRONG_DIRECTION409Interrupt callbackThe 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_CANCELLABLE409Interrupt cancelThe interrupt is not in a cancellable (pending) status — nothing to cancel.
RESOLUTION_IN_PROGRESS409Interrupt resolve, interrupt callback, pipeline resumeAnother resolution of this interrupt holds the lock. Transient: the same request succeeds once the holder finishes.
INTERRUPT_NOT_PENDING409Interrupt resolve, interrupt callback, pipeline resumeThe interrupt has already been settled — resolved, cancelled or expired. An interrupt is answerable exactly once.
INTERRUPT_EXPIRED409Interrupt resolve, interrupt callback, pipeline resumeThe interrupt's deadline passed before the answer came. Nobody answered in time, rather than somebody already did.
TURN_IN_PROGRESS409Session turnThe session is already running a turn. Wait for session.turn.completed and retry.
NO_ASSOCIATED_WORKFLOW409Session turnThe session has no workflow to run a turn against. Attach one first.
EXECUTION_NOT_RUNNING409Playground stopThere is no running execution to stop.
NOT_A_CRON_TRIGGER400Trigger history, trigger reset-stateThe endpoint only applies to cron-family triggers.
CRON_STATE_UNAVAILABLE503Trigger history, trigger reset-stateThe 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

On this page