Factuarea APIDevelopers

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:

PieceFieldWhat it decides
Whentrigger_typeThe event the rule listens to, in resource.action form (invoice.paid)
IfconditionsA condition tree over the fields of that event. An empty {} means no condition: the rule always acts
DoactionsThe 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 "" in description clears 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}/versions lists 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 statusWhat it means
pendingQueued, not started
runningIn flight
completedEvery step applied its effect
failedFinished without producing its effect, and not reprocessable
dead_letteredParked, waiting for a manual replay
blockedStopped 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_index is 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.

LimitWhat it boundsReason and error code
Chain depthHops "action → event → rule", three by defaultchain_depth_exceeded · 422 automation_chain_depth_exceeded
Frequency limitRuns admitted per minute and company. A brake on peaks, not on volumerate_limit_exceeded · 429 automation_rate_limit_exceeded
Monthly budgetRuns the plan allows per periodmonthly_budget_exhausted · 402 automation_monthly_budget_exhausted
Consecutive failuresA streak of failed runs pauses the rule by itselfEvent 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

On this page

Need a hand?Contact support