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:
| Trigger | Starts a run when… |
|---|---|
| HTTP Trigger | a 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 Trigger | the bot receives an update — long-polling (no public URL needed) or webhook mode |
| Slack Trigger | a Slack event arrives (the Events API handshake is handled for you) |
| WhatsApp Trigger | a WhatsApp Business message arrives (webhook verify + normalized payload) |
| Discord Trigger | a Discord message arrives via the Gateway WebSocket |
| WebSocket Trigger | a message arrives on the workflow’s WebSocket endpoint |
| Web Chat | a visitor sends a message through the embeddable chat widget the workflow serves |
| Form | someone submits the HTML form the workflow serves (GET renders it, POST runs the flow) |
| Schedule | a 5-field cron expression or fixed interval fires (IANA timezone aware) |
| File Event | a watched path sees a file created/changed/deleted |
| Receive Message | an inbound bot-platform payload (Gmail, Slack, Telegram, Discord or a generic webhook) arrives and is normalized to one shape (paired with Send Message) |
| Table Trigger | a row in a Flowdrome Table is created, updated or deleted — one run per change, carrying { table, rowId, event, row } |
| Inject | you press the button — a test fixture with a configurable payload |
| Manual | the workflow is invoked as a run-once job |
| Error Trigger | another 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:
| Method | Path template | Output port |
|---|---|---|
GET | /users/{id} | (blank) |
POST | /orders | createOrder |
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 /orders → post_orders. A row with the ANY method drops the verb, so
/health → health. 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/1042against/orders/{orderId}gives{{ $json.pathParams.orderId }}.query— the query string parsed into an object, so?status=activereads 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
404itself. - 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
unmatchedbranch then ends with nothing downstream, and the caller gets the ordinary202receipt.
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 node | an async job — 202 with a run id, collected afterwards from the status URL |
| contains one | a 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 did | The reply |
|---|---|
| Executed an HTTP Response node | that 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 one | 500 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 one | 202 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_MISSING — body 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=5is what puts the error in front of the caller. Without it this workflow is an async job: the caller gets its202and reads"error": "body path is missing: customer"fromGET /api/serve/runs/{runId}afterwards. Add an HTTP Response node instead and the same failure comes back as a500synchronously.- 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_LIMITEDwith aRetry-Afterheader), 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 withFLOWDROME_ALLOW_CODE_NODES=0in locked-down deployments).
Choosing a trigger
| You want… | Use |
|---|---|
| An API endpoint / webhook receiver | HTTP 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 bot | Telegram / Slack / WhatsApp / Discord Trigger (or WebSocket for your own client — see the AI guide) |
| Chat on your own website | Web Chat — the workflow serves an embeddable widget |
| Collect structured input from people | Form — the workflow serves the HTML form itself |
| React to a row changing in a Flowdrome Table | Table Trigger |
| React to files landing in a directory | File Event |
| Decouple two flows with a queue | Message Queue (utility.mq) between them — or a Thread Pool frame for in-run fan-out |
| A batch job your scheduler invokes | Manual (job mode) |
| A cleanup path when things fail | Error Trigger |