Skip to content

Triggers

A schedule runs a workflow on time. A trigger runs it when something happens — a form is submitted, a customer emails an invoice, last night’s import finishes. Same workflow, same run history, same parameters; only the doorbell changes.

Catalyst ships three kinds:

  • Webhook — runs when an HTTP request hits a private URL.
  • Email — runs when mail arrives at a private address.
  • Chained — runs after another workflow finishes.
  1. Open the workflow and click Triggers in the toolbar — the lightning bolt next to Schedule. (Save the workflow first; a trigger hangs off a saved workflow.)

  2. Click New Trigger. Give it a Name if you like — “Invoices inbox”, “Zapier form submissions”. It’s optional and only used to label the trigger in the list.

  3. Pick the Trigger type: Webhook, Email, or Chained. The type is fixed once created — the URL, the settings and the event shape all hang off it, so switching means making a new trigger.

  4. Fill in that type’s settings — authentication and response mode for a webhook, sender and subject filters for email, the source workflow for a chained one.

  5. Set Default parameters (used whenever the event doesn’t supply a value) and, if the run should depend on what arrived, Map from event expressions. See Mapping event data into parameters.

  6. Leave Enabled checked and click Create. Unchecking it pauses the trigger — the URL or address stays reserved but stops accepting anything.

If you chose a signed webhook, Catalyst shows the signing secret exactly once on the next screen. Copy it into whatever will be calling the webhook before you click Done — you can Reveal secret or Rotate it later from the trigger’s row, but rotating breaks the old one immediately.

This is the one idea to get right. The email (or request, or upstream run) is not an input parameter. Input parameters are the values you declare on the Input node and fill in by hand. The thing that started the run arrives separately, as an object named trigger that every node can read — exactly the way a node reads another node’s output:

Input node → {{ input.parameters.<name> }} values you declared and filled in
Trigger event → {{ trigger.<field> }} what arrived: the email, the request, the upstream run

So a workflow that processes email typically has an Input node with no parameters at all. The LLM node just reads the message directly:

From: {{ trigger.from_name }} <{{ trigger.from }}>
Subject: {{ trigger.subject }}
{{ trigger.text }}

and a send_email tool node answers it with "to": "{{ trigger.from }}". Nothing is declared, nothing is mapped — trigger is simply there.

There are three ways to consume the event, and you can mix them:

| you want to… | do this | |---|---| | read a field in any node (prompt, template, tool arguments, output mapping) | write {{ trigger.subject }}, {{ trigger.attachments.0.text }}, {{ trigger.body.order_id }} right in that field | | loop over attachments in a prompt | Jinja works in every field: {% for a in trigger.attachments %}### {{ a.filename }}\n{{ a.text }}{% endfor %} | | open an attachment as a file in a Python node | it’s already on disk: open('./inputs/attachments/invoice.pdf', 'rb') — nothing to map | | keep the workflow runnable by hand and by trigger | declare an input parameter, give it a default, and in the trigger’s Map from event box fill it from the event ({{ trigger.subject }}) — see Mapping event data |

Two consequences worth knowing:

  • A manual Run has no trigger. If a node references {{ trigger.… }} and you press Run, that node fails with “Template reference unresolved” — there was no event. Use the trigger’s Send test button instead (it fabricates a small event of the right kind), or go through the mapping route above if the same workflow must also run by hand.
  • The event is saved with the run. Open the run in History and look at trigger under the node outputs — that’s exactly what arrived, attachments and all, so you can write your prompt against real data.

A webhook trigger gives the workflow a private URL:

https://catalystapi.voov.ai/api/triggers/wh/<token>

The token is random and 32 characters long — the URL is the credential, so treat it like a password. Copy it from the trigger’s row in the panel. POST is accepted by default; tick GET or PUT under Methods if the sender needs them.

None — the unguessable URL is the only secret. Fine for Zapier- or Make-style senders that can’t sign requests.

Terminal window
curl -X POST https://catalystapi.voov.ai/api/triggers/wh/<token> \
-H 'Content-Type: application/json' \
-d '{"topic": "quarterly numbers"}'

Shared secret — the caller sends the secret in an X-Catalyst-Secret header (or as Authorization: Bearer <secret>).

Terminal window
curl -X POST https://catalystapi.voov.ai/api/triggers/wh/<token> \
-H 'Content-Type: application/json' \
-H 'X-Catalyst-Secret: whsec_…' \
-d '{"topic": "quarterly numbers"}'

HMAC signature — the caller signs the raw body. Catalyst accepts two styles, so most senders work unchanged:

  • Standard Webhooks (what Svix-based providers send): webhook-id, webhook-timestamp and webhook-signature: v1,<base64>, signed over id.timestamp.body. Timestamps more than five minutes off are rejected, so replays don’t work. The secret is issued in whsec_<base64> form precisely so Svix client libraries can sign with it as-is.
  • GitHub style: X-Hub-Signature-256: sha256=<hex> over the raw body — paste the secret into a GitHub repository webhook and it just works.
Terminal window
curl -X POST https://catalystapi.voov.ai/api/triggers/wh/<token> \
-H 'Content-Type: application/json' \
-H 'webhook-id: msg_2b1c' \
-H 'webhook-timestamp: 1755950400' \
-H 'webhook-signature: v1,K5oT…=' \
-d '{"topic": "quarterly numbers"}'
  • Acknowledge immediately (202) — the default. Catalyst answers straight away with {"status": "accepted", "run_id": …, "status_url": …} and the run continues in the background. Use this for anything that takes real time.
  • Wait for the result (≤ 90s) — Catalyst holds the connection until the run finishes and returns {"status": "completed", "run_id": …, "outputs": {…}}, where outputs are your Output nodes’ results keyed by node. Set the Wait timeout in seconds (max 90 — Cloudflare cuts the connection at 100). If the run outlasts the timeout, the response degrades to the 202 shape and the run carries on.

Polling after a 202. status_url is …/api/triggers/wh/<token>/runs/<run_id> — the same URL family as the webhook, so the caller polls it with the same credentials it used to call the webhook (nothing for None, the secret in X-Catalyst-Secret or Authorization: Bearer for the other two modes — HMAC callers just present the secret, no signature needed on a GET). It returns {"run_id", "status", "created_at", "started_at", "completed_at", "outputs", "error?"}; outputs is null until the run is terminal. Only runs started by that webhook are visible through it, and only the Output-node results — intermediate node payloads stay private to you.

Terminal window
curl -s "$STATUS_URL" -H "X-Catalyst-Secret: $SECRET"
# {"run_id":"…","status":"completed","outputs":{"out":{"subject":"Order 42"}}, …}

This is the standard async-API shape (202 + a status resource you poll with the same auth), the same thing GitHub Actions, Stripe and the LLM batch APIs do; Zapier-style senders that never poll can simply ignore the field.

As with every trigger kind, the request is read as {{ trigger.<field> }} — it is not an input parameter (see Where the event goes).

The whole request is handed to the run as a trigger event, readable from any node:

  • {{ trigger.method }} and {{ trigger.path }} — how it was called.
  • {{ trigger.body.<key> }} — parsed JSON or form body.
  • {{ trigger.query.<key> }} — query-string values.
  • {{ trigger.headers.<name> }} — headers, lowercased, with authorization, cookie and signature headers stripped out.
  • {{ trigger.files.0.url }} and {{ trigger.files.0.filename }} — uploaded files.
  • {{ trigger.client_ip }} — the caller’s IP address.

JSON, application/x-www-form-urlencoded and multipart/form-data bodies are parsed; anything else arrives as text in {{ trigger.body }}.

Parameters fill themselves in. For webhooks, any top-level key in the JSON or form body whose name matches one of the workflow’s parameters fills that parameter automatically — POST {"topic": "…"} at a workflow with a topic parameter and there’s nothing to configure.

Uploads land on disk. Files posted as multipart/form-data are stored and also staged into the run’s Python sandbox at ./inputs/attachments/<filename>, so a Python node can open them directly.

  • Bodies are capped at 10 MB inline (413 above that); each multipart file has its own size cap.
  • 60 requests per minute per trigger, then 429.
  • A paused trigger answers 410; an unknown token answers 404; a method you didn’t enable answers 405; a failed signature answers 401.
  • If a required parameter ends up unset, no run starts — the delivery is logged as an error and the caller gets a 422 explaining which parameter was missing.
  • Retries don’t double-fire. If the sender includes a webhook-id (Standard Webhooks) or an Idempotency-Key header, a repeat of one Catalyst already accepted is answered with {"status": "duplicate"} and no second run.

An email trigger gives the workflow its own private inbox:

The local part is built from the trigger’s name plus random characters, so the address is unguessable and readable enough to keep in an address book. Mail sent to it starts a run. Send from anywhere, forward from your mail client, or add it as a Bcc on a filter.

  • Allowed senders — one per line, either full addresses ([email protected]) or a whole domain (@liu.edu, which also covers its subdomains). Leave it empty and anyone who knows the address can start runs.
  • Subject contains — a case-insensitive substring, e.g. invoice. Mail that doesn’t match is logged as filtered and no run starts.
  • Require SPF/DKIM pass — only meaningful behind a mail provider that stamps an Authentication-Results header. Cloudflare’s inbound email doesn’t pass those verdicts through, so on the default setup turning this on rejects everything.

Everything below is read as {{ trigger.<field> }} — no input parameter involved (see Where the event goes).

| field | what it holds | |---|---| | trigger.kind | "email" | | trigger.from, trigger.from_name | sender address (lower-cased) and display name | | trigger.to, trigger.cc | lists of addresses | | trigger.subject, trigger.date | subject line; ISO date from the message | | trigger.text | the plain-text body; HTML-only mail is converted to text | | trigger.html | the HTML body when the message had one, else empty | | trigger.message_id, trigger.in_reply_to | threading headers, handy for “is this a reply?” | | trigger.attachments | a list — see below | | trigger.auth.spf / .dkim / .dmarc | verdicts when the provider stamps Authentication-Results; otherwise empty | | trigger.headers | a few passthrough headers: reply-to, list-id, auto-submitted, … | | trigger.id, trigger.received_at | the delivery id and time |

Each entry of trigger.attachments has:

| field | what it holds | |---|---| | filename, content_type, size | as sent | | url | where Catalyst stored the file — usable in a vision LLM node’s Images field or as a file / image parameter | | text | extracted text for documents (PDF, Word, PowerPoint, HTML, ePub, plain text) and a capped preview of spreadsheets / CSV / JSON (rendered as tables, ~20k characters, with a note when cut) — empty for images and other binaries | | inline | true for images embedded in the body (signature logos, pasted screenshots) |

Every attachment is also staged into the run’s Python sandbox at ./inputs/attachments/<filename>, so a Python node reads it straight off disk.

Spreadsheets and CSVs come through as a preview: enough for a model to summarise, count or spot values, but capped and typed as text. When you need the whole sheet with real numbers and dates — totals, joins, anything beyond a glance — point a Python node at the file instead:

import glob, pandas as pd
frames = [pd.read_excel(p) for p in glob.glob('./inputs/attachments/*.xlsx')]
result = {"rows": sum(len(f) for f in frames)}

Three copy-paste snippets that cover most email workflows:

# LLM node prompt — read the mail and every document attached to it
From: {{ trigger.from_name }} <{{ trigger.from }}>
Subject: {{ trigger.subject }}
{{ trigger.text }}
{% for a in trigger.attachments %}{% if a.text %}
### {{ a.filename }}
{{ a.text }}
{% endif %}{% endfor %}
// Tool node — answer the sender (server_id "__builtin__", tool_name "send_email")
{ "to": "{{ trigger.from }}",
"subject": "Re: {{ trigger.subject }}",
"body": "{{ summarize.text }}",
"format": "markdown" }
# Output node mapping — keep what mattered in the run record
subject → {{ trigger.subject }}
attachments → {{ trigger.attachments | length }}
answer → {{ summarize.text }}

A complete worked example — inbox → LLM extraction → reply to the sender — is in Reply to emails with an LLM.

  • Mail to an unknown or paused address is rejected at the SMTP door, so the sender gets a bounce rather than silence.
  • Mail from a sender not on the allowed list is also bounced — they’ll know it didn’t go through.
  • Mail that fails the subject filter is accepted and quietly logged as filtered. That’s deliberate: a subject filter is a routing rule, not a rejection, so a colleague’s unrelated reply doesn’t come back marked undeliverable.

A chained trigger runs this workflow after another workflow finishes — no URL, no address, nothing public. Pick the Source workflow, then Run when source:

  • Completed — only on success (the default).
  • Errored — only on failure.
  • Either (any terminal status) — both.

The downstream run can read everything about the upstream one:

  • {{ trigger.source.workflow_name }} and {{ trigger.source.status }}
  • {{ trigger.source.outputs.<output node id> }} — the upstream Output nodes’ results
  • {{ trigger.source.error }} — the failure message, when it failed
  • {{ trigger.source.parameters }} — what the upstream run was given

Chains are depth-capped at five hops, so a workflow that ends up triggering itself stops instead of looping forever — the blocked hop shows up in the deliveries log. Failures downstream never affect the run that started the chain.

Default parameters are the values used when the event doesn’t supply one. Map from event is how a run depends on what actually arrived: for each parameter, write a {{ trigger.… }} expression — the same syntax every node uses. The form pre-fills sensible placeholders ({{ trigger.body.<name> }} for webhooks, {{ trigger.subject }} or {{ trigger.attachments.0.url }} for email), and Available variables lists everything the selected kind offers.

When more than one thing could set a parameter, this is the order:

  1. A Map from event expression wins.
  2. Otherwise, for webhooks, a matching top-level key in the body — an event that carries a value beats the defaults you set on the trigger (empty values don’t count).
  3. Otherwise the default parameter you typed into the trigger.
  4. Otherwise the parameter’s own default from the Input node.

An expression that renders empty is treated as “not set”, so the next rule down applies.

You don’t have to map anything at all: the whole event is available as {{ trigger.… }} in every node of the workflow — in a Template’s text, an LLM node’s prompt, a Python node’s inputs — and it’s saved with the run, so you can look at exactly what arrived.

Each trigger’s row has a Deliveries view: one line per thing that arrived, newest first, with a status badge.

  • accepted — a run started. View run opens it.
  • filtered — a rule declined it (sender not allowed, subject didn’t match, chain too deep).
  • rejected — refused at the door (bad signature, too large, rate limited).
  • error — something arrived and was accepted, but the run couldn’t start — usually a required parameter with nothing to fill it. The reason is on the line.

Send test fires a synthetic event of the right kind — a small JSON body for a webhook, a fake message for an email trigger, a pretend upstream completion for a chained one — so you can check your mapping, your parameters and the workflow itself before wiring up the real sender. It goes through the same path as a real delivery, so it appears in Deliveries and produces a real run.

The row also shows how often the trigger has fired, when it last fired, and how that run turned out.

Trigger runs are ordinary runs: they’re in the workflow’s history with their per-node outputs, they show up in the floating runs indicator, and each one is badged Webhook, Email or Chained so you can tell at a glance what started it — alongside the existing Scheduled badge. The event itself is stored with the run under the trigger node, which makes debugging “why did it do that?” a matter of reading what came in.