Triggers

A trigger is the node that starts a run. Everything downstream is reaction; the trigger decides when there is something to react to — and, just as importantly, what the run’s initial payload is. Flowdrome ships fifteen:

TriggerStarts a run when…
HTTP Triggera request hits the workflow’s webhook path (method, path and content type are configurable) — or fill in its Routes table and the one trigger serves a whole API, a port per route
Telegram Triggerthe bot receives an update — long-polling (no public URL needed) or webhook mode
Slack Triggera Slack event arrives (the Events API handshake is handled for you)
WhatsApp Triggera WhatsApp Business message arrives (webhook verify + normalized payload)
Discord Triggera Discord message arrives via the Gateway WebSocket
WebSocket Triggera message arrives on the workflow’s WebSocket endpoint
Web Chata visitor sends a message through the embeddable chat widget the workflow serves
Formsomeone submits the HTML form the workflow serves (GET renders it, POST runs the flow)
Schedulea 5-field cron expression or fixed interval fires (IANA timezone aware)
File Eventa watched path sees a file created/changed/deleted
Receive Messagean inbound bot-platform payload (Gmail, Slack, Telegram, Discord or a generic webhook) arrives and is normalized to one shape (paired with Send Message)
Table Triggera row in a Flowdrome Table is created, updated or deleted — one run per change, carrying { table, rowId, event, row }
Injectyou press the button — a test fixture with a configurable payload
Manualthe workflow is invoked as a run-once job
Error Triggeranother node in the run fails — it starts the recovery lane

Each reference page above shows the trigger on the canvas, wired, with a real run’s payload — the fastest way to learn a trigger’s output shape is to look at one captured run and start copying field paths.

The production trigger model

The same workflow behaves differently in the editor and in a deployed app — deliberately. The motivating problem: you need sample data to build a flow, but the deployed artifact must not fire your test scaffolding. Flowdrome resolves this with three trigger roles:

  • Inject is an inert test fixture. It exists to hand the graph a payload while you build — its properties hold the sample content (JSON, text, even binary with an encoding). In a deployed workflow it never fires — dead weight by design, so you can leave your test scaffolding in the document forever. No “remember to delete the test trigger” step, no prod incident from forgetting it.
  • Manual is the run-once entry. A deployed workflow invoked in job mode executes once from the Manual trigger and reports its outcome — cron-from-the-outside, CI steps, batch jobs.
  • Real triggers serve. Once deployed, the host’s serve layer takes over: it binds the workflow’s HTTP/WebSocket/form/chat endpoints, starts its schedules, long-polls Telegram, answers the Telegram/Slack/WhatsApp webhooks and polls a Table’s change feed — continuously, with run history on the host dashboard. Three of the triggers above are not armed by a deployed host yet: Discord (the Gateway client is deferred), File Event, which normalizes a watcher event handed to it rather than watching a directory itself, and Receive Message, which normalizes a payload another route delivers to it. All three still run in the editor.

“Promote to production” is therefore not a rewrite: you test with an Inject sitting next to the real trigger, deploy, and the real trigger takes over while the Inject goes dormant.

What a trigger emits

A trigger’s output is a content wrapper describing what arrived — kind, content type, and the payload — which the engine unwraps at the next node boundary, so downstream nodes see the content (the webhook body, the Telegram update, the file event) without ceremony. Text arrives as text (no false “invalid JSON” complaints), JSON as parsed values, binary as first-class binary items. The metadata (headers, query, method for HTTP) rides the run’s envelope where expressions can still reach it.

Routes: many endpoints from one HTTP trigger

The HTTP trigger’s default shape is a single Method + Path. Fill in its Routes table instead and that one trigger serves a whole API — every row is a method plus a path template, and every row gets its own output port:

MethodPath templateOutput port
GET/users/{id}(blank)
POST/orderscreateOrder

Routes replace the single endpoint. The moment the table holds one row with a path, the trigger’s own Method and Path stop being served and the editor hides them — the host registers the route table instead. (They used to keep showing as live settings while the endpoint they described had already stopped existing, which is exactly as confusing as it sounds.)

The output port can be left blank

Leave Output port empty and the id is derived from the method and the path: GET /users/{id}get_users_id, POST /orderspost_orders. A row with the ANY method drops the verb, so /healthhealth. Two rows that derive the same id are de-collided with a numeric suffix (get_a_b, then get_a_b_2). Type a port yourself and it is kept verbatim — including repeating one deliberately, which fans several routes into a single handler.

Derivation happens in one shared function (cleanHttpTriggerRoutes, in packages/runtime-core/src/node-ports.mjs), used both to draw the ports in the editor and to route the request at run time. So the port you see is the port that fires — there is no separate registration step to keep in sync, and no way for the editor’s name and the runtime’s name to drift apart.

What the port carries

Routes are matched top to bottom, first match wins. The matching route’s port is the one the runtime fires, and the payload it carries is the request body plus two folded-in extras:

  • pathParams — the {…} segments of the template, decoded: GET /orders/1042 against /orders/{orderId} gives {{ $json.pathParams.orderId }}.
  • query — the query string parsed into an object, so ?status=active reads as {{ $json.query.status }}. Repeated keys collapse to the last value.

The request wrapper is preserved around them, so headers.*, request.method and request.path are still reachable on the envelope.

Unmatched and Auth failed are optional lanes

Both trailing ports are optional — the editor renders them in italic and never reports one as an unwired-output error. Wiring them is a decision about what your callers should see, not a requirement.

Unmatched fires when no route claimed the request, with $json.methodMismatch telling you which kind of miss it was: true when the path matched a route’s template but the verb did not (answer 405), false for a path no route has (answer 404). What happens if you leave it unwired is worth knowing exactly:

  • A path no route registers never reaches the workflow at all — the host has nothing to match and answers 404 itself.
  • A known path with the wrong verb does start a run (the host registers each route’s path template for every verb so the trigger can do the authoritative method match). The unmatched branch then ends with nothing downstream, and the caller gets the ordinary 202 receipt.

So wire Unmatched whenever a real 405 — or a 404 in your own shape — matters to the caller, and pair it with an HTTP Response node.

Auth failed fires instead of the endpoint port when inbound auth is configured on the trigger and a request fails it, carrying the status and error it would have used. It is inert for a header-less test inject, so a configured webhook is still designable. Left unwired, the host itself answers the rejection with the real code — 401 (or 403) and { error: { code, message }, runId }, never a 202 receipt that looks like acceptance. Wire the lane only when you want to customize the rejection (your own body, a redirect, logging) with an HTTP Response node.

Turn on Publish OpenAPI spec and the trigger also serves an OpenAPI 3.0 document built from its own route table (at Spec path, /openapi.json by default), so callers — and Flowdrome’s own OpenAPI node — can discover every endpoint without you writing a spec by hand.

What an HTTP caller gets back

A deployed HTTP trigger has two contracts, and one thing decides which one a caller gets: whether the workflow contains an HTTP Response node.

The workflow…The caller gets
has no HTTP Response nodean async job202 with a run id, collected afterwards from the status URL
contains onea synchronous reply — the caller holds the connection for the whole run, and the node picks the status, content type and body

Paths are namespaced under the workflow either way — a workflow named Dog Shop serving GET /buy answers at /dog-shop/buy. The host has one listener that every deployed workflow shares, so the workflow name is what keeps their routes apart; two workflows can both define /buy without shadowing each other. There is no per-trigger port.

Async: a run id, not a held socket

With no HTTP Response node in the graph, the trigger answers the moment the run starts and never holds the caller’s connection for the length of the run:

POST /dog-shop/webhook/new
→ 202 { "runId": "run_a1b2c3", "status": "running", "statusUrl": "/api/serve/runs/run_a1b2c3" }

This is the default because a connection is not a durable thing. A socket cannot survive a restart, so a reply that only exists on one is a reply that can be lost — whereas a run id can be redeemed later, from a different connection, after a restart, by a client that reconnects tomorrow.

202 is an acceptance, not a result. It says the run started. A workflow that started and then threw still answered 202; the failure is only visible by collecting the run. A client that treats 202 as “it worked” is wrong.

GET /api/serve/runs/{runId}

GET /api/serve/runs/run_a1b2c3
→ 200 { "runId": "run_a1b2c3", "workflowId": "orders", "status": "succeeded",
        "durationMs": 412, "output": {  } }

status is queued, running, paused, succeeded, failed or canceled. output appears once the run has one, error (the message of the failure that ended the run) when it failed, and an unknown id is a 404. If the run executed an HTTP Response node, its code is reported as httpStatus — so a caller that took the receipt still learns the workflow answered 401 or 409; the field is absent when no response node ran. No session is needed — the unguessable run id is the capability — and the endpoint resolves from the live engine first and the durable run record second, so it outlives both eviction and a host restart. It returns the run’s outcome only, never the node-by-node execution trace.

Prefer: wait=N — the result if it’s quick

Polling is the right default, but it is overkill for a workflow that finishes in 20 ms. Send the RFC 7240 header Prefer: wait=N (seconds) on the request and the host holds it for up to that long:

POST /dog-shop/buy      Prefer: wait=5
→ 200 { "runId": "run_a1b2c3", "status": "succeeded", "durationMs": 412, "output": {  } }

A run that finishes inside the budget answers 200 with its real outcome, or 500 with the error if it failed. A run that does not finish gets the ordinary 202 receipt and carries on — it is never cancelled or restarted because the caller stopped waiting. The budget is capped at 60 seconds: the header arrives from the caller, and a request asking to be held for an hour is a request to occupy a connection for an hour. An absent, unparseable, zero or negative wait behaves exactly as if the header were not there.

Synchronous: answering inline

An HTTP Response node anywhere in the graph declares a synchronous contract: the caller gets that node’s status code and body instead of a receipt. Use it whenever the caller needs a real answer — a 201 with the created id, a 403, a 409. It is a passthrough with an output port, so whatever arrived at it leaves it unchanged and you wire the rest of the work after it. Two response nodes on the two branches of an approval gate (202 approved / 403 rejected) is the canonical webhook-with-a-human pattern.

Of the node’s Headers config, the host’s serve layer applies content-type and nothing else, so treat the reply as status + content type + body. Put anything else a client must see in the body.

On a deployed host the reply is built from that node’s recorded output once the run reaches a terminal state — so the node decides what the caller gets, not when it arrives. The caller holds the connection for the whole run, and nothing on the host bounds that (see the caveat above: Prefer: wait is not a cap here). If a workflow can run long, keep it on the async contract.

Because the graph can reach the node or not, there are three outcomes:

What the run didThe reply
Executed an HTTP Response nodethat node’s status code and body. Several nodes on different branches are fine — the reply comes from the one that actually ran
Failed before reaching one500 with the run’s outcome: { "runId": …, "status": "failed", "error": "…" }. The caller already held the socket for the whole run, so a contentless receipt would tell it nothing it could act on
Succeeded without reaching one202 with the run id — the workflow deliberately chose not to reply on that branch, and there is no response to invent

Retries: Idempotency-Key

Handing back an id invites retries, and a retry must not do the work twice. Send an optional Idempotency-Key header and a repeat returns the original run id without starting a second run — even across a host restart, and even if ten retries arrive at once. Keys are scoped per deployed workflow and last 24 hours. Send no key and every request starts its own run.

Making a bad request fail properly

Nothing checks a webhook body’s shape by default. The HTTP trigger validates the method, the path, the content type and the body size (the 4 MB default cap below), and then hands whatever arrived to the graph — it has no body-schema option. The HTTP Request node is equally permissive: it accepts any JSON as its input. So a request missing a field you needed does not fail at the door; it fails wherever the missing field first hurts, which is usually after you have already called someone else’s API.

There are two ways to make it fail earlier, and they answer different questions.

Body path — fail before the call goes out

Set Body path on the HTTP Request node to the dotted path of the value you are sending. It does two jobs: it picks what becomes the request body, and a path that is not present throws HTTP_REQUEST_BODY_MISSINGbody path is missing: <path>before the outbound request is built or sent. No network call, no half-finished write downstream.

Take a workflow named Signup API with an HTTP Trigger on POST /signup wired to an HTTP Request node that POSTs to https://api.example.com/customers, with Body path set to customer. A request that forgot the customer:

curl -i -X POST http://your-host:4801/signup-api/signup \
  -H 'content-type: application/json' \
  -H 'Prefer: wait=5' \
  -d '{"source":"web"}'
HTTP/1.1 500 Internal Server Error

{ "runId": "run_9f3c1a", "workflowId": "signup-api", "status": "failed",
  "durationMs": 6, "error": "body path is missing: customer" }

And the same endpoint with the field present:

curl -i -X POST http://your-host:4801/signup-api/signup \
  -H 'content-type: application/json' \
  -H 'Prefer: wait=5' \
  -d '{"customer":{"name":"Ada","email":"ada@example.com"}}'
HTTP/1.1 200 OK

{ "runId": "run_a1b2c3", "workflowId": "signup-api", "status": "succeeded",
  "durationMs": 214, "output": {  } }

Two things about that example are worth being explicit about:

  • Prefer: wait=5 is what puts the error in front of the caller. Without it this workflow is an async job: the caller gets its 202 and reads "error": "body path is missing: customer" from GET /api/serve/runs/{runId} afterwards. Add an HTTP Response node instead and the same failure comes back as a 500 synchronously.
  • Present-but-null counts as present. The check is whether the path exists, so {"customer": null} passes it and {} does not. Body path guards the value you were going to send — it is not a schema.

Validate JSON Schema — check the whole shape

When you need more than “the field I am forwarding exists” — types, enums, required lists, a body you are not sending straight on — put a Validate JSON Schema node right after the trigger. Give it an inline Schema, or leave the Schema reference pointing at a named one.

It has two output ports. With Route invalid on (the default) a bad body leaves on invalid carrying valid: false, the original input, and an errors array whose entries each have a path, a code and a message — wire that to an HTTP Response node and you have a real 400 with a real explanation. Turn Route invalid off and a bad body fails the run with VALIDATION_FAILED instead of routing.

Webhook security

  • The HTTP trigger validates method, path and content type at the door.
  • Per-trigger ingress guards are first-class HTTP-trigger properties, enforced by the host in a fixed order before the body is read: an IP/CIDR allowlist (miss → 403 IP_NOT_ALLOWED), a per-client-IP rate limit (token bucket; over → 429 RATE_LIMITED with a Retry-After header), and a body size cap (over → 413 PAYLOAD_TOO_LARGE; 4 MB by default, 1 MB on the bot-webhook triggers). Allowlist and rate limit default off; the body cap defaults on.
  • For signed webhooks (payment providers, GitHub-style signatures), pair the trigger with the Crypto node: HMAC the raw body and compare with a timing-safe equality check — the shipped Signed Webhook demo is exactly this.
  • Telegram webhook mode can verify Telegram’s secret-token header; polling mode avoids inbound exposure entirely.
  • Outbound HTTP from the web-request nodes (HTTP Request, API, RSS Read) passes an SSRF guard: private-range and loopback targets are refused unless the operator explicitly allows them (FLOWDROME_HTTP_ALLOW_PRIVATE=1) — so those nodes can’t be tricked into probing your internal network with a crafted URL. Code nodes (JavaScript / C# Script) make raw requests and are not behind this guard — authoring a code node means running trusted code (disable code nodes entirely with FLOWDROME_ALLOW_CODE_NODES=0 in locked-down deployments).

Choosing a trigger

You want…Use
An API endpoint / webhook receiverHTTP Trigger — add an HTTP Response node when the caller needs an answer inline; without one the webhook replies 202 with a run id
”Every morning at 9” / “every 30 s”Schedule (cron or interval)
A chat botTelegram / Slack / WhatsApp / Discord Trigger (or WebSocket for your own client — see the AI guide)
Chat on your own websiteWeb Chat — the workflow serves an embeddable widget
Collect structured input from peopleForm — the workflow serves the HTML form itself
React to a row changing in a Flowdrome TableTable Trigger
React to files landing in a directoryFile Event
Decouple two flows with a queueMessage Queue (utility.mq) between them — or a Thread Pool frame for in-run fan-out
A batch job your scheduler invokesManual (job mode)
A cleanup path when things failError Trigger