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 the portfolio rules of an account.
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/companies/{company}/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/companies/{company}/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/companies/{company}/automations/catalog/triggers/{trigger}/fields
for the trigger you picked, and build the condition against the fields it lists.
Twelve action types exist today: notify_in_app, notify_channel,
emit_webhook, create_calendar_event, send_document_email,
send_payment_reminder, change_status, tag_entity and, for tasks,
create_task, change_task_status, assign_task and add_task_comment — see
Tasks and projects. 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. create_calendar_event is
retired: the calendar it fed no longer exists, so the name stays in the
vocabulary — a version sealed earlier still reads — but no adapter runs it and
its step produces no effect. Use create_task instead.
The type enum of an action grew. If your integration validates
actions[].type against a closed list, add create_task,
change_task_status, assign_task and add_task_comment, and keep
create_calendar_event recognizable so that older rule versions still parse.
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/companies/{company}/automations/rules/{rule}/activate.
Activation checks the plan’s active-rule cap. It does not pre-check your
monthly execution budget: that is checked when a run is admitted. Read
the usage endpoint
for both counters before activating.
curl -X POST https://api.factuarea.com/v1/companies/{company}/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"
}
}
]
}'Tasks and projects
Automations reach the task module in both directions: its events
start rules, and four actions create and change tasks. Both need the tasks
module.
Triggers
The 21 task and project events —
task.*, task_comment.*, task_time_entry.* and project.* — are valid
triggers. Like every trigger governed by a module, they exist only for a company
with the tasks module: the catalog does not offer them otherwise, and saving a
rule that uses one returns 422. Conditions read the fields each event publishes —
for a task, among others its status, column, priority, project, assignee, due
date and completion date; ask
GET /v1/companies/{company}/automations/catalog/triggers/{trigger}/fields
— and the run freezes the data.object of the event as its payload.
Actions
| Action | What it does | Parameters |
|---|---|---|
create_task | Creates a task | project_id (required: an active project of your company), title (required, up to 500 characters), description (Markdown), priority, assignee_mode (user, role or trigger_actor) with assignee_user_id or assignee_role (owner, admin or member), label_ids, the due date and link_to_trigger |
change_task_status | Moves the task of the trigger | target: the id of a column of the task's project, or planned or archived |
assign_task | Assigns the task of the trigger | assignee_user_id: a member of your company |
add_task_comment | Comments on the task of the trigger | body: Markdown, from 1 to 10,000 characters |
{
"name": "Follow up approved quotes",
"scope": "empresa",
"trigger_type": "quote.approved",
"conditions": {},
"actions": [
{
"type": "create_task",
"order": 0,
"parameters": {
"project_id": "0193a4f2-7c20-7a11-8b52-4d6e8f0a2c01",
"title": "Prepare the delivery of the approved quote",
"priority": "high",
"assignee_mode": "role",
"assignee_role": "admin",
"link_to_trigger": true
}
}
]
}- The task of the trigger. The last three actions act on it: the task itself
in
task.*events, and the task a comment or a time entry belongs to intask_comment.*andtask_time_entry.*events. With any other trigger, or if the task no longer exists, the step is discarded withtask_target_not_found. - Due date of
create_task. Send eitherdue_on(an explicit date) ordue_from_payload_pathwithdue_offset_days(a date field of the frozen payload plus a number of days) — never both. A field that is missing or is not a date fails the step with a message that names it: no date is invented. With neither, the task has no due date. link_to_triggerlinks the new task to the entity that fired the rule — an invoice, a quote, a contact… — so it appears among that entity's linked tasks. If the trigger has no linkable entity, the task is created unlinked and the step trace says so.- Assignee.
assignee_mode: "trigger_actor"assigns the user who caused the event, and leaves the task unassigned when the event does not identify a member.rolepicks the first user of the company with that role, in a deterministic order. A user who is not a member of the company fails the step and creates nothing. - Comments by an automation have no user as author (their
author.typeisautomation); nobody can edit them and an admin can delete them. - References are checked against your company — the project, the assignee, the
labels and a
targetthat is a column — when the rule is saved (422, with the same message whether the reference belongs to another company or does not exist) and again when it runs. - Discarded steps. A
create_taskwhose project was archived after the rule was saved is discarded withtask_project_not_found, and can be replayed once you unarchive it; a company that lost thetasksmodule getsmodule_not_accessible, replayable when the module is back. - Loops are cut. A rule
task.created→create_taskreaches the chain-depth cap like any other chain. - The dry run validates parameters, references, subject and module exactly like a real run, and creates no task and no comment.
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.
PATCH /v1/companies/{company}/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/companies/{company}/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/companies/{company}/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/companies/{company}/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/companies/{company}/automations/runs
— filter by automation_rule_id, status or a created[…] window —,
GET /v1/companies/{company}/automations/runs/{run}
and GET /v1/companies/{company}/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/companies/{company}/automations/runs/{run}/replay
rearms every parked step of a run, and
POST /v1/companies/{company}/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
The plan bounds how many rules you can activate. Separately, four guardrails
bound execution: a run stopped by one of those execution limits is recorded as
blocked with its typed reason — blocked is not failed, and reading the reason
tells the two apart. Rejection at the active-rule cap creates no run.
| Limit | What it bounds | Reason and error code |
|---|---|---|
| Active automation rules | Active, non-deleted rules of your company; checked when activating or resuming | Activation rejected · 402 automation_active_rules_limit_exceeded |
| 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/companies/{company}/automations/usage is where you stand against the monthly budget:
consumed, limit (null means no automatic ceiling, 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.
The same response also carries active_rules: {count, limit}: count is the
company’s active, non-deleted rules; drafts and paused rules do not count.
limit is the plan cap, including zero when no activation is permitted, or
null when there is no automatic ceiling. Enterprise capacity is agreed by
contract. The public v1 response and MCP get_automation_usage expose the same
pair, without limit_mode.
Response fragment:
{
"data": {
"active_rules": { "count": 7, "limit": 7 }
}
}When count >= limit for a fixed cap, activating or resuming a rule is rejected
with automation_active_rules_limit_exceeded: v1 returns 402 with
error.type: payment_required_error and error.details: {active_rules, limit}.
Pause an active rule with POST /v1/companies/{company}/automations/rules/{rule}/pause or upgrade the
plan before trying again. MCP activate_automation_rule returns JSON-RPC
-32008, with the same code in data.code, data.http_status: 402 and the same
pair in data.details; the app reports HTTP 422 with the same code.
Rules already active above the cap keep running and can still be edited or paused. This cap does not pause them automatically; it only prevents further activations. Pausing frees one active-rule place, and resuming checks the cap again. The monthly execution budget remains separate and is checked on execution.
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
A rule declares which tax IDs 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 the portfolio of the
account that owns it, and delivers the notice to that company, the titular NIF of
the account. They were designed for accounting firms (gestorías), which are
accounts like any other.
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.
What a cartera rule watches. The other real tax IDs of the account, plus the
tax IDs that another account puts within its reach through a relation in force at
level operate, a granted access or a
managed account. A relation at level view, or one
that was revoked or has ended, brings nothing in. The rule never watches the
company that owns it: if you want the same automation for that company, create it
with scope empresa.
The universe is not a stored list. It is worked out from each event, and it is
checked again at every step: when the candidate rules of an event are chosen,
before the run is written, inside each step before its action runs, and when you
replay a run or a step. A tax ID that has been archived, deactivated or
transferred to another account, or whose relation was revoked, ended or lowered
to view, fails the check: no run is written for it or, if the run already
exists, the step stops as blocked with reason tenant_mismatch and its action
never runs. Replaying does not get around it.
Who can create a cartera rule. Only the titular company of an account that
has the gestoria module granted, by plan (empresario and enterprise) or by a
company override. The first thing asked is whether the company is the titular of
its account: an additional tax ID —one the account bought, received by transfer
or registered— never has a portfolio of its own, even if the module was granted to
it, because the portfolio belongs to the account and its titular represents it.
You do not need a second tax ID to write the rule; until the account has one, it
simply never fires.
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 company is not the titular of its account, so
no plan unlocks it; the name is inherited and stays because it is part of the
contract). 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 five of the twelve action types are accepted in cartera.
notify_in_app, notify_channel and emit_webhook notify the titular company or touch no
entity at all, and create_task creates the task in a project of the titular company itself
— link_to_trigger is rejected in cartera, because the entity that fired the
rule belongs to another tax ID. The retired create_calendar_event keeps its
place in this group but does nothing. The other seven — send_document_email,
send_payment_reminder, change_status, tag_entity, change_task_status,
assign_task and add_task_comment — would take a document or a task of
another tax ID 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.
Whose run it is, and its subject. A portfolio run is stored under the
titular company of the account: it is its rule, its notices land in its inbox
and its runs consume its monthly budget and its frequency limit, so the budget is
spent in proportion to the portfolio. One event can start more than one run when
its tax ID is in the portfolio of more than one titular, for example a tax ID
granted to another account at level operate: each titular with a matching rule
gets its own run, against its own budget. Each run names in subject_company the tax ID it acted
upon, which is null for empresa runs. The block travels even when that tax ID
can no longer be read: id and name come back null instead of the block
disappearing. The tax ID itself does not see the portfolio runs of its titular:
they belong to the titular and hold the frozen definition of its rule.
Filter runs with
GET /v1/companies/{company}/automations/runs?subject_company_id=… and rules with
GET /v1/companies/{company}/automations/rules?scope=cartera. Filtering by a tax
ID outside the portfolio returns an empty page, never an error, so the filter
cannot be used to find out which tax IDs exist.
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.