Skip to main content

Triggers

What starts an automation when no one is calling — a schedule, an inbound webhook, or a manual fire — with every firing recorded.

Overview​

A trigger points at something runnable — an agent, a tool, an orchestration or an eval — and says what starts it:

  • schedule — a 5-field cron expression in UTC, fired by the runtime;
  • webhook — an inbound call signed with the trigger's own secret;
  • event — a subscription to an event the upstream runtime emits internally, matched by pattern;
  • manual — fired through this API.

Each firing is a record of its own: what started it, what it produced, and whether it succeeded. That is the difference between a trigger and a cron entry in your own infrastructure — the attempt is auditable, and the result is linked to the thing it created.

This module is a verbatim mirror of the runtime: every field, method, status code and error shape is the runtime's own, re-rooted under the project in the path. It serves two collections — /triggers and /trigger-firings.

A plan caps how many triggers you hold, and how often each fires

How many. Counted across every project the billing owner pays for: Free holds 3, Pro 30, Business 500; an Enterprise ceiling is set by contract. Creating one past that, directly or by deploying a formation, answers 403 plan_limit_reached, with the plan and the limit in details.

How often. A schedule may not fire more often than the plan allows — hourly on Free, every 5 minutes on Pro, every minute on Business, by contract on Enterprise. A cron that fires more often than that answers 403 plan_limit_reached with resource: "trigger_interval" and the minimum, in seconds, as limit. It is measured on the closest two firings the schedule produces, so 0 9,17 * * * is read as eight hours rather than as twice a day. Updating a trigger is checked the same way, and a webhook trigger has no schedule to measure. Run an agent on a schedule shows the refusal on Free.

Both caps also apply to a formation template that declares triggers, before it reaches the runtime — the details.field names the declaration that crossed the line.

These are the two deviations from the verbatim mirror on this module: both refusals are naturali's, not the runtime's. A cron naturali cannot parse is forwarded, so an invalid expression is still the runtime's to refuse.

See the OpenAPI spec for the full endpoint and schema reference, or browse it rendered under API Reference → Triggers.

Data Model​

Trigger​

FieldTypeDescription
idstringPublic trigger ID.
project_idstringThe owning project.
namestring
descriptionstring, nullable
typestringmanual, webhook, schedule or event.
target_typestringorchestration, agent, tool or eval.
target_idstringThe resource to run.
actionstring, nullableTool targets only — the action for mcp tools.
inputobject, nullableStatic input, shallow-merged under fire-time input.
tool_contextobject, nullableWrite-only tool context every firing forwards to the run it starts — see A schedule carries its own tool context. Never returned by a read.
cronstring, nullable5-field cron expression (UTC); schedule triggers only.
event_patternstring, nullableEvent subscription pattern (*, prefix.*, or an exact event name); event triggers only — see Event triggers.
activebooleanAn inactive trigger neither fires nor accepts a webhook.
policy_idstring, nullableOptional boundary policy restricting what a firing may do.
next_fire_atstring (date-time), nullableRead-only, schedule triggers only.
created_at / updated_atstring (date-time)

TriggerFiring​

FieldTypeDescription
idstringPublic firing ID.
trigger_idstringThe trigger that fired.
project_idstring
sourcestringmanual, webhook, schedule or event — how this firing started.
statusstringpending, running, succeeded or failed.
inputobject, nullableThe effective input for this firing.
resultobject, nullable{ target_type, result_id, status, output } — the thing the firing produced.
errorobject, nullable{ code, message, meta } when it failed.
started_at / completed_atstring (date-time), nullable
created_at / updated_atstring (date-time)

Key Concepts​

Firing is recorded, not fire-and-forget​

POST /v1/projects/{project_id}/triggers/{trigger_id}/fire creates a firing and returns it. Read one back with GET /v1/projects/{project_id}/trigger-firings/{firing_id}, or list one trigger's log with GET /v1/projects/{project_id}/trigger-firings — trigger_id is required there, so the log is always read per trigger rather than project-wide. result.result_id is the handle to what it produced — a generation, an orchestration run, or an eval run — so a scheduled job is traceable to its output rather than to a log line. Run an agent on a schedule fires a new schedule by hand, follows result_id to its generation and reads the log.

A negative balance stops a firing by hand​

Whatever the trigger targets goes on to generate, so POST /v1/projects/{project_id}/triggers/{trigger_id}/fire answers 402 insufficient_credit and records no firing while the project owes for usage already served. So does a signed webhook call. It is the project owner's balance. See A negative balance stops managed generation.

A Free account that has used its plan's monthly runs answers 403 plan_limit_reached with resource: "runs" here as well, on its own credential too. See A plan's run allowance can stop generation.

A trigger's scheduled firings are not refused this way: they never pass through this API, so nothing here sees them. Reading the log is never refused either.

A debt turns a schedule off​

Since a scheduled firing is not refused, it is stopped instead. When the reconciliation finds the project owner's balance negative, every schedule trigger they hold is set active: false — across all their projects, on the same balance. A manual, webhook or event trigger is untouched: those fire because something already refused above asked them to.

Turning one back on is yours to do after a top-up; nothing re-enables a schedule for you. Everything a debt stopped is listed at GET /v1/users/me/stops, to restore or dismiss one by one — see Restoring what billing stopped.

Only a negative balance does this. A zero balance keeps its schedules, which is the same line 402 insufficient_credit is drawn on. The stop lands within one reconciliation interval of the debt appearing, so a schedule may fire a few more times before it stops.

Static input sits under fire-time input​

A trigger's input is a default: whatever the firing supplies is shallow-merged on top. So a schedule can carry the constant part of a payload while a manual or webhook fire varies the rest.

A schedule carries its own tool context​

A tool that authorizes per call reads its credential from the tool_context of whoever started the work. A scheduled firing has no such caller, so a trigger carries the bag itself: tool_context on the trigger is forwarded to every run a firing starts, and each key reaches the endpoint as one X-Naturali-Context-<key> header.

It is write-only — accepted on create and update, never returned on a read, so the record cannot be used to recover a value.

A value may be a {{secret:...}} reference rather than a literal, which is what a stored bag should carry: the credential stays in the secret store and only its name sits on the trigger, so rotating the secret changes the next firing without touching the trigger. The reference is checked against the project when it is written — one naming a secret that does not exist is refused there, not at fire time.

A manual fire has a caller, so it may pass tool_context of its own, shallow-merged per key over the stored bag. That half is forwarded exactly as written: a {{secret:...}} is resolved only in the trigger's stored bag, never in one supplied at fire time.

The webhook secret is shown once​

GET /v1/projects/{project_id}/triggers/{trigger_id}/secret reads the current signing secret, and POST /v1/projects/{project_id}/triggers/{trigger_id}/rotate-secret replaces it. Rotating invalidates the old one immediately, so update the caller before you rotate.

A webhook trigger is called at its own URL​

A webhook trigger fires when the system you connect calls

POST https://api.naturali.ai/v1/projects/{project_id}/hooks/triggers/{trigger_id}

It takes no bearer token: the caller signs the raw body with the trigger's secret and sends X-Naturali-Signature: sha256=<hex HMAC-SHA256 of the body>. The body, at most 1 MiB of JSON, is the fire-time input; a value that is not an object arrives as { "payload": … }. The SDK and the CLI have no method for it, since the caller is not a client of this API.

import { createHmac } from 'node:crypto';

const body = JSON.stringify({ order_id: 'ord_123' });
const digest = createHmac('sha256', process.env.TRIGGER_SECRET!)
.update(body)
.digest('hex');

await fetch(
'https://api.naturali.ai/v1/projects/proj_V1StGXR8Z5jdHi6B/hooks/triggers/trg_V1StGXR8Z5jdHi6B',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Naturali-Signature': `sha256=${digest}`,
},
body,
}
);
StatusWhen
202Accepted: { firing_id, trigger_id, status }. The firing runs in the background; read it back by firing_id.
404No such project, or no webhook trigger by that id in it.
401The signature is missing or not made with the current secret.
402 / 403Signed, but the owner's balance or run allowance refuses it.
409The trigger is inactive.
400The body is not JSON, or the input does not fit the target.
413The body is over 1 MiB.

Event triggers​

An event trigger subscribes directly to the upstream runtime's internal event bus instead of receiving a signed webhook call — the same event, delivered in-process rather than as a network round trip back into the API. Firing works the same as any other trigger: a TriggerFiring is created, and its input is the event's payload.

event_pattern uses the same grammar as a webhook subscription's events entries: documents.ingested matches that event only, documents.* matches every event in that namespace, and * matches everything the project emits.

Delivery is best-effort and unordered — the runtime's event bus makes no stronger guarantee, so an event trigger is for reactive automation whose value is promptness, not for work that must not be lost. Keep a schedule trigger over the same condition as a backstop when it must not be lost; the two compose, and a target that is idempotent per resource makes the overlap harmless.

Because a reactive edge can feed itself, two guards apply that do not apply to any other trigger type:

  • A causal loop is refused, not run. Every event carries the chain of trigger firings that produced it. A trigger refuses to extend a chain that already names it, or one that has already run several hops deep — either refusal records a failed firing and files an event_trigger_loop exception so the loop is triaged rather than merely stopped.
  • Quota admission runs before dispatch. An event trigger never passes through the same request path an API call does, so its firing is checked against the project's quotas before it runs — a wide pattern on an expensive target is still bounded.

Scheduling an eval is the point of eval targets​

A schedule trigger against an eval is how a suite runs nightly instead of when someone remembers: the eval run it produces carries trigger_id, so a scored regression is attributable to the schedule that found it.

Who may do what​

Every route needs any project member, except the webhook call, which needs the trigger's signature instead.

Examples​

Schedule a nightly eval​

naturali create-trigger \
--project-id proj_V1StGXR8Z5jdHi6B \
--name nightly-billing-evals \
--type schedule \
--cron "0 3 * * *" \
--target-type eval \
--target-id eval_V1StGXR8Z5jdHi6B

Fire one by hand with extra input​

naturali fire-trigger \
--project-id proj_V1StGXR8Z5jdHi6B \
--trigger-id trg_V1StGXR8Z5jdHi6B \
--input '{ "agent_version": 4 }'

Audit what one trigger has done​

naturali list-trigger-firings \
--project-id proj_V1StGXR8Z5jdHi6B \
--trigger-id trg_V1StGXR8Z5jdHi6B \
--limit 20

Rotate the webhook secret​

naturali rotate-trigger-secret \
--project-id proj_V1StGXR8Z5jdHi6B \
--trigger-id trg_V1StGXR8Z5jdHi6B