Skip to main content

Triggers

A trigger starts a pipeline run. Each pipeline can have multiple triggers, and each trigger belongs to exactly one pipeline.

Trigger types

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-Secret header 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_hashes table

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.

`accepted_events`
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-level action field equals "submitted"
  • {"review.state": ["changes_requested", "commented"]}: matches when review.state is 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_seconds must be an integer >= 60 (default 60)
  • input_template defaults 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’s v0= 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:

Flood protection
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:

`queue_timeout_seconds` (per pipeline)
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

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:

TriggerEvent log
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.