Triggers
A trigger starts a pipeline run. Each pipeline can have multiple triggers, and each trigger belongs to exactly one pipeline.
Trigger types
| Type | Description |
|---|---|
manual |
Fire a run by hand via POST /api/v1/runs with an input payload |
webhook |
Inbound HTTP POST with Hash-based Message Authentication Code (HMAC) secret validation |
cron |
Schedule-based, evaluated as wall-clock schedules in a named Internet Assigned Numbers Authority (IANA) timezone |
polling |
Polls a connector for a condition |
agent_signal |
Fired by another pipeline’s output |
ongoing |
Keeps a pipeline topped up to max_concurrent_runs in-flight runs |
slack_app_mention |
Fires when the Slack bot is @-mentioned |
Manual
The default way to fire a run by hand. POST /api/v1/runs with an input payload.
Webhook
An inbound HTTP POST with HMAC secret validation.
- Each trigger instance gets a system-generated
webhook_secret - Inbound path:
POST /api/v1/triggers/{trigger_id}/webhook - Auth:
X-Modulo-Webhook-Secretheader validated with a constant-time HMAC comparison payload_mapping(JSONPath or JMESPath) maps raw payload fields to the entry agent’s input schema. It is required; unmapped fields are not passed through.- Flood protection via configurable
max_concurrent_runs(default 1), evaluated on each fire as a count of this trigger’s unfinished runs - see the Flood protection section below - Deduplication via a configurable payload dedup window (default 60s) using a
webhook_dedup_hashestable
Filtering webhook deliveries
Two optional config keys control which deliveries a webhook trigger accepts. Both are evaluated after HMAC validation succeeds.
accepted_events
An allowlist of payload top-level keys. The trigger checks that the incoming payload contains an object under each named key. A delivery is rejected if any listed key is missing from the payload.
This is the common trap: the field name reads like event types (e.g. pull_request_review), but the values are keys that must exist in the JSON body, not event-type strings.
For a GitHub pull_request_review delivery, the top-level keys are review and pull_request. There is no pull_request_review key. So accepted_events: ["pull_request_review"] matches nothing and rejects every delivery.
| Config value | Matches a GitHub review webhook? | Why |
|---|---|---|
["pull_request_review"] |
No | No top-level pull_request_review key exists in the payload |
["review"] |
Yes | The payload contains a review object |
event_filters
A map of dotted payload paths to allowed values. Use this to gate on specific event types and values. The trigger walks the dot-separated path against the payload and checks whether the resolved value is in the allow-list.
{"action": "submitted"}: matches when the top-levelactionfield equals"submitted"{"review.state": ["changes_requested", "commented"]}: matches whenreview.stateis one of the listed values
event_filters fails closed: a path that cannot be resolved against the payload, or a value not in the allow-list, rejects the delivery.
To accept only a specific kind of event, use event_filters, not accepted_events.
Worked example: GitHub review webhook (submit only)
A GitHub pull_request_review webhook delivers a payload where the top-level keys include action (e.g. "submitted", "edited") and review (the review object).
Wrong: rejects every delivery:
Pipeline config
{
"accepted_events": ["pull_request_review"]
}
No pull_request_review key exists in the payload. The delivery is silently rejected.
Right: accepts only submitted reviews:
Pipeline config
{
"event_filters": { "action": "submitted" }
}
GitHub sets a top-level action field on review events. This filter matches "submitted" and rejects everything else.
Combining both (accept review payloads, but only submitted ones):
Pipeline config
{
"accepted_events": ["review"],
"event_filters": { "action": "submitted" }
}
Silent rejection caveat
A misconfigured filter produces no error message: the delivery is simply rejected. Nothing warns you. To verify a filter works, send a real delivery and confirm it appears in the TriggerEvent log with validation_result: accepted.
Cron
Schedule-based triggers evaluated as wall-clock schedules in a named IANA timezone. next_fire_at is persisted in UTC.
Pipeline config
{
"schedule": "0 9 * * 1-5",
"timezone": "Europe/London",
"input_template": { "task": "daily report" }
}
Polling
Polls a connector for a condition.
Pipeline config
{
"connector_instance_id": "...",
"poll_query": "state == 'ready'",
"condition_expression": "...",
"poll_interval_seconds": 300
}
Agent signal
Fired by another pipeline’s output.
Pipeline config
{
"source_pipeline_id": "...",
"source_node_id": "...",
"signal_schema_id": "..."
}
Ongoing
Keeps a pipeline topped up to max_concurrent_runs in-flight (active or queued) runs, like a daemon or worker pool.
Pipeline config
{
"scan_interval_seconds": 60,
"input_template": {}
}
scan_interval_secondsmust be an integer >= 60 (default 60)input_templatedefaults to{}- A daily spend limit is required at create/update
- Auto-deactivates after N consecutive no-delivery terminal runs (default 5) and raises a notification
Slack app-mention
Fires when a Slack bot is @-mentioned. The trigger receives Slack Events API app_mention payloads at POST /api/v1/triggers/{trigger_id}/slack.
Pipeline config
{
"signing_secret": "<slack-app-signing-secret>",
"payload_mapping": { "task": "text" }
}
signing_secret(required): the Slack app’s signing secret. Each delivery’sv0=HMAC signature and request timestamp are verified against it (constant-time comparison, plus or minus 300 seconds replay window); deliveries that fail verification are rejected.payload_mapping(optional): maps mention fields (text,user,channel,thread_ts) to the entry agent’s input schema, using the same mapping mechanism as webhook triggers.- Deliveries are deduplicated by Slack
event_id, so retried Events API deliveries do not start duplicate runs.
Flood protection
Each trigger has its own concurrency limit, max_concurrent_runs (default 1). The limit is evaluated on every fire as a count of that trigger’s unfinished runs - queued, running, or waiting for a human - not a stored counter. Nothing is incremented or decremented: each fire counts the trigger’s unfinished runs and compares the result against the limit.
What happens at the limit depends on how the trigger fires:
| How the trigger fires | Behavior at the limit |
|---|---|
| Webhook delivery | The delivery is accepted and the run queues as pending. A concurrency_limit_reached TriggerEvent is recorded alongside the run’s accepted event. The sender gets a normal acceptance, not an error. |
| Webhook replay | The replay is refused with HTTP 429 after a concurrency_limit_reached TriggerEvent is recorded, so the operator can retry later. |
| Cron | The fire is consumed: no run is created, a concurrency_limit_reached TriggerEvent is recorded, and the work waits for the next scheduled fire. |
| Polling | The cycle is skipped with a concurrency_limit_reached TriggerEvent; the next polling interval evaluates again. |
| Agent signal | The signal is skipped with a concurrency_limit_reached TriggerEvent; no run is created. |
| Slack app-mention | The mention is accepted and the run queues as pending, with a concurrency_limit_reached TriggerEvent recorded. |
| Manual | Not gated by the trigger limit. The run is created and queues like any other. |
This is a per-trigger cap. It is a different mechanism from the per-pipeline backpressure guard, which refuses new fires when a pipeline’s pending queue is already deep or already old, and only for webhook, cron and polling fires. Both layers, and the pipeline-level capacity limits themselves, are described in Concurrency and capacity sizing.
Queueing, capacity, and queue timeout
A fire that passes the trigger’s own concurrency limit can still find no capacity to run: the pipeline is at its max_concurrent_runs, or the organization is at its run or sandbox cap. A run blocked that way stays pending and waits indefinitely by default. It is not failed and nothing is silently dropped. The old instance-wide 120-minute queued timeout is retired, and new runs are never failed with capacity_timeout.
queue_timeout_seconds (per pipeline)
A pipeline can put a bound on that wait with queue_timeout_seconds, a nullable pipeline setting:
| Value | Meaning |
|---|---|
empty / null (default) |
Never expire. A capacity-queued run waits until a slot frees. |
1 to 604800 (7 days) |
A run that has been queued longer than this is ended as a supersession. |
The window is anchored on when the run was queued (its creation time), and it applies only to runs blocked by capacity (a pipeline_capacity or org_capacity_limited marker). Runs that were never dispatched, and runs whose enqueue failed, keep their own separate timeouts.
Set it in the pipeline editor toolbar (Queue timeout (s); leave it empty for never), through PATCH /api/v1/pipelines/{pipeline_id}, or in modulo apply as pipelines[].queue_timeout_seconds.
What expiry does: supersession, not failure
When the timeout expires, the run ends with status rejected and error code queue.expired. That is a supersession: the run was dropped before it started, so it is not a failure, does not enter failure counts or thresholds, and pages nobody. The runs list and run detail show it as expired while queued, and analytics records it under its own reason (queue_expired), never as a failure.
Escalation is separate from this setting. Capacity alerting and the capacity/queue panel report a queue that is building or not draining whether or not a queue timeout is configured. See Concurrency and capacity sizing.
Choosing a value
| Trigger type | Guidance |
|---|---|
| Cron | Set it to the schedule’s period, so a fire that can no longer be useful drops instead of running hours late. An hourly cron sets 3600. |
| Webhook | Leave it empty. Each delivery is a discrete event, and repeat deliveries are already suppressed by the deduplication window (and coalesced when they are repeat CI-failure events). |
Manual and Model Context Protocol (MCP) trigger_pipeline |
Leave it empty. A run a person asked for should wait rather than quietly disappear. |
TriggerEvent log
Every trigger activation creates a TriggerEvent record with:
| Field | Description |
|---|---|
trigger_id |
The trigger that fired |
trigger_type |
manual, webhook, cron, polling, agent_signal, ongoing, slack_app_mention |
raw_payload_hash |
Hash only - never the raw payload, which may contain secrets |
received_at |
When the event was received |
validation_result |
accepted | hmac_failed | deduplicated | event_type_not_accepted | event_value_filter_not_accepted | concurrency_limit_reached | rate_limited | coalesced | others (varies by trigger type) |
run_id |
The run started by this event, if any |
error_detail |
Error details on failure |
Operators can view the log and replay any logged event.
Deleting a trigger
When a trigger is deleted, in-flight runs continue against their snapshot to completion. No new runs are initiated.
Org-wide pause
An org admin can pause new trigger-initiated runs org-wide. Running pipelines continue to completion. Manual runs, MCP trigger_pipeline, and scheduled reports are never paused.
Related
- Pipelines: the directed-graph execution model
- Connectors: tool integrations for pipeline nodes
- Cost Management: daily spend limits required by ongoing triggers
- Concurrency and capacity sizing: per-pipeline backpressure and the capacity limits that sit above per-trigger flood protection