Automations
The when, if and do model of the rule engine — versions, the dry run that materialises nothing, run history and steps, replay, the engine limits and portfolio rules for accounting firms.
The automation engine turns an event into work. A rule listens to one event
type, a condition decides whether that particular event is its case, and an
ordered list of actions runs. Everything hangs from
https://api.factuarea.com/v1/automations, needs the automations plan module
and uses four fine-grained scopes — automations:read, automations:write,
automations:delete and automation_runs:read. See
Scopes & permissions.
When, if and do
A rule has exactly three moving pieces:
| Piece | Field | What it decides |
|---|---|---|
| When | trigger_type | The event the rule listens to, in resource.action form (invoice.paid) |
| If | conditions | A condition tree over the fields of that event. An empty {} means no condition: the rule always acts |
| Do | actions | The actions to run when an event matches, in the order you declare them |
Ask the catalog instead of guessing. GET /v1/automations/catalog returns the
triggers your company can subscribe to — already filtered by the modules your
plan includes, so a trigger governed by a module you have not contracted is not
offered at all — plus the closed set of operators and combinators a condition
accepts and the actions that have a registered adapter in this deployment. It is
deterministic: two consecutive calls with no configuration change return the same
body, so cache it and diff it.
The evaluable fields of a trigger are a second call. They are deliberately
out of the catalog: around a hundred visible triggers with dozens of properties
each would make a document of hundreds of kilobytes to end up using the fields
of one. Ask
GET /v1/automations/catalog/triggers/{trigger}/fields
for the trigger you picked, and build the condition against the fields it lists.
Eight action types exist today: notify_in_app, notify_channel,
emit_webhook, create_calendar_event, send_document_email,
send_payment_reminder, change_status and tag_entity. The catalog announces
only the ones with a wired adapter, never the declared type list — picking a type
without an adapter would produce a rule whose step is skipped at run time.
A new rule is born inactive. Creating one seals version 1 and leaves it in
draft: it hears nothing until you activate it with
POST /v1/automations/rules/{rule}/activate.
Activation deliberately does not pre-check your monthly budget — read
the usage endpoint
first if you want to anticipate that.
curl -X POST https://api.factuarea.com/v1/automations/rules \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Warn me on a large payment",
"scope": "empresa",
"trigger_type": "invoice.paid",
"conditions": { "field": "total", "operator": "gte", "value": 1000 },
"actions": [
{
"type": "notify_in_app",
"order": 0,
"parameters": {
"recipient_type": "role",
"role": "admin",
"category": "invoice",
"title": "Large payment",
"message": "An invoice over 1000 EUR has been paid"
}
}
]
}'Every edit seals a version
Editing a rule never rewrites its previous definition. It seals a new version
and bumps current_version, and every run keeps a pointer to the exact version
it executed, so a run from weeks ago stays readable after the rule has moved on.
PUT /v1/automations/rules/{rule}updates partially: omitted fields keep their value, and""indescriptionclears it. The status is left untouched — editing an active rule leaves it active, running the new version from its next event onwards.- Pausing and activating touch no definition, so they seal no version.
GET /v1/automations/rules/{rule}/versionslists the sealed versions and.../versions/{version}returns one, with the condition tree and the actions frozen as they were.
The rule_version of a run points at exactly that number. It can lag behind
current_version, and that is the point.
The dry run materialises nothing
POST /v1/automations/rules/{rule}/dry_run
answers what a rule would do against a sample event. It executes nothing: no
e-mail leaves, no webhook delivery is created, no entity is mutated, no run or
step row is written, and neither the monthly budget nor the engine's frequency
limit is consumed. That is why it takes the read scope even though it is a
POST — the sample event has to travel in the body.
The answer carries matched, one condition_trace entry per evaluated leaf in
evaluation order, and one steps entry per action with what it would resolve to.
A condition that cannot be evaluated comes back as condition_error with a
200, not as an HTTP failure: seeing why a rule cannot be evaluated before
activating it is exactly what the dry run is for.
If the rule acts on documents — send a document by e-mail, send a payment
reminder, change a status, tag an entity — pass event_aggregate_id with the
id of a real document of yours. Without it the dry run reports that no step
would run, and a perfectly correct rule looks broken.
curl -X POST https://api.factuarea.com/v1/automations/rules/{rule}/dry_run \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"event_type": "invoice.paid",
"event_payload": { "total": 1200, "status": "paid" }
}'Runs and their steps
Every admitted event produces a run, and every action of the frozen
definition produces a step. A run carries the definition it executed
(rule_version + rule_snapshot) and the payload of the event that triggered it
(event_payload), both frozen at execution time.
| Run status | What it means |
|---|---|
pending | Queued, not started |
running | In flight |
completed | Every step applied its effect |
failed | Finished without producing its effect, and not reprocessable |
dead_lettered | Parked, waiting for a manual replay |
blocked | Stopped by an engine limit before executing |
Steps come back always in execution order, because step_index — zero-based
— is the identity of a step: it is what you replay and what the
automation_run.step_dead_lettered event publishes as data.step_index. Each
step carries its action_type, its frozen parameters, the result its adapter
returned and a replayable flag.
Two orthogonal axes explain an outcome. discard_reason is the classified
reason, from a closed catalog — condition_not_matched, chain_depth_exceeded,
rate_limit_exceeded, monthly_budget_exhausted, step_attempts_exhausted… —
so branch on it. last_error is the raw technical message, meant for debugging.
discard_reason_label is the human label of the reason and is always in
Spanish: it belongs to the vocabulary of the engine and does not follow
Accept-Language.
Read the history with
GET /v1/automations/runs
— filter by automation_rule_id, status or a created[…] window —,
GET /v1/automations/runs/{run}
and GET /v1/automations/runs/{run}/steps. All three paginate by cursor; see
Pagination.
Replay executes the work again, for real
Two operations rearm parked work: POST /v1/automations/runs/{run}/replay
rearms every parked step of a run, and
POST /v1/automations/runs/{run}/steps/{step_index}/replay
rearms a single one, leaving its siblings with their state, their reason and
their timestamps untouched.
The rearmed steps execute for real: they send e-mail, deliver webhooks and call third parties. This is not an inert retry — confirm with the account owner before calling it.
- Only steps parked with a replayable reason are rearmed. A run still in
flight, or one that finished cleanly, is rejected with 422
automation_replay_not_allowed. - The response is 202 — accepted and queued, not finished — and its body
carries ids only, deliberately without a
status, because "queued" is not one of the run statuses. Check the outcome with the run, or more cheaply with its steps. - Calling it twice does not duplicate the effect: the engine rechecks the state of each step inside its own lock.
step_indexis the index published by the steps endpoint, not the position of the action in the rule definition. An index outside the range returns 404, exactly like a run that is not yours.
The limits of the engine
Four guardrails bound what the engine will do on your behalf. A run stopped by
any of them is recorded as blocked with its typed reason — blocked is not
failed, and reading the reason tells the two apart.
| Limit | What it bounds | Reason and error code |
|---|---|---|
| Chain depth | Hops "action → event → rule", three by default | chain_depth_exceeded · 422 automation_chain_depth_exceeded |
| Frequency limit | Runs admitted per minute and company. A brake on peaks, not on volume | rate_limit_exceeded · 429 automation_rate_limit_exceeded |
| Monthly budget | Runs the plan allows per period | monthly_budget_exhausted · 402 automation_monthly_budget_exhausted |
| Consecutive failures | A streak of failed runs pauses the rule by itself | Event automation_rule.auto_paused with consecutive_failures |
An event produced by a person or by an integration enters at depth zero, so
only what an automation itself caused spends the chain budget. A rule that reacts
to automation_run.failed and fails again therefore cannot loop forever.
GET /v1/automations/usage is where you stand against the monthly budget:
consumed, limit (null means no cap, never "unknown" — treat it as no
progress bar, not as zero), reset_at already resolved, the period as
YYYY-MM and the threshold_percent at which the platform starts warning the
account owner. That percentage travels so your warning and ours cannot drift
apart.
A single step also has a cap on attempts. When it runs out, the step parks as
dead_lettered with step_attempts_exhausted and waits for a replay — that is
the only state that allows re-entry.
Portfolio rules for accounting firms
A rule declares which companies it watches in its optional scope field:
empresa — the value it takes when you omit it — watches the events of the
company that owns it, and cartera watches the events of every client company
an accounting firm manages, delivering the notice to the firm.
The scope is immutable. You choose it when you create the rule, and to change
it you create another rule. An update that sends a different value is rejected
with automation_rule_scope_immutable;
send the scope the rule already has, or omit the field.
cartera needs the accounting-firm module and a company that is not itself
managed by another one. The catalog returns both scopes always, each with
available and, when it is not, an unavailable_reason: module_not_granted
(upgrading the plan grants it) or company_is_managed_child (the hierarchy is
one level deep, so no plan unlocks it). That is how you tell "not contracted"
from "does not exist". Creating a cartera rule without that right returns 403
automation_portfolio_scope_not_available.
Only four of the eight action types are accepted in cartera: the ones that
notify. notify_in_app, notify_channel, emit_webhook and
create_calendar_event warn the firm or touch no entity at all. The other four —
send_document_email, send_payment_reminder, change_status and tag_entity —
would take a document of the managed company as their subject, which the
multi-tenant boundary does not allow. They are rejected when the rule is created
or edited, never at run time, with 422
automation_portfolio_scope_forbids_action.
Each action of the catalog carries supports_portfolio_scope so you can filter
before writing the rule.
A portfolio run is stored under the accounting firm and names the managed
company it acted upon in subject_company, which is null for empresa runs.
The block travels even when that company can no longer be read: id and name
come back null instead of the block disappearing. Filter runs with
GET /v1/automations/runs?subject_company_id=… and rules with
GET /v1/automations/rules?scope=cartera.
In test mode nothing leaves the sandbox
A fact_test_ key operates against an isolated sandbox company, and there the
engine behaves the same up to the last instant: the trigger matches, the run is
admitted and recorded, and every step is neutralised right before producing its
effect. The step closes with sandbox_neutralized and the run finishes
completed — a neutralised effect is the intended outcome, not a failure. No
e-mail is sent, no webhook is delivered and no document changes. See
Test mode & sandbox.
Next steps
the seven life-cycle events the engine emits, and why they are valid triggers themselves.
the same surface as tools for an agent.
the whole life cycle from the terminal.
every code the engine emits, with its cause and what to do.
API reference.
API reference.
API reference.