Guide 09 · Developer docs

Automation, jobs and integration

A job is a trigger, conditions and an ordered list of actions, set up under Customization › Orchestration in the admin; jobs were called hooks before the module was renamed, and the API's routes keep that name. What starts a job, what it can do, branches and loops, email, scheduled work, integrations with other systems, and every event and action.

L2–L3
Unit 02 · What starts a job

Guarding against recursion

A job that updates a record on that record's update event can start itself again. Which writes raise events on this platform and which do not, the four ways a loop actually forms, and the guard that stops it: a condition on the field that changed.

Who this is for. Anyone whose job writes records of the kind it listens to, or calls the site's own API. Afterwards you can say whether a job can loop, and you have a guard to put on every job that might.

Which writes start jobs

Whether a write raises an entity event depends on where it runs, not on which action made it:

The write is made byRaises entity events?
An asynchronous job, or a loop element running in the backgroundNo. Background workers do not raise entity events
A synchronous job, which runs inside the web request that started itYes
Any job, through an HTTP Request to the site's own API (/cdna-api/…)Yes: the API call is a web request like any other
The platform's own background events: abandoned carts, back in stock, scheduled publishingAlways, whoever wrote the record

So an asynchronous job with an Update Record action cannot start itself through the record it writes. That is platform behaviour rather than a promise, and a job changed to synchronous, or rewritten to use the API, inherits the loop. Guard every job that writes the kind of record it listens to.

The four loops that actually happen

  1. A job writes through the site's API. An Updated trigger on orders, and an HTTP Request that changes the order with PUT /cdna-api/orders/order/{id}. The PUT raises Updated, which runs the job again. The worked integration has exactly this shape, and its first condition is the guard.
  2. A synchronous web-event job writes records that another job listens to, and that job calls the web event.
  3. Two jobs trigger each other with Trigger job actions. Pass a depth in the event data and stop at a limit (Chaining one job to another).
  4. A job calls its own web event URL with an HTTP Request.

The symptom is the same each time: Batch Logs fills with runs of one job, a few seconds apart, for the same record (A job is triggering itself in a loop). Unpublish the job to stop it, then add the guard.

The guard: carry on only when the watched field changed

Put the field the job reacts to under the trigger's Pre-Event properties, and make the first action a template condition that carries on only when that field's value is different from before. Then make sure the job never writes that field. Its own write then changes other fields only, the second run finds the watched field unchanged, and stops. With {financialStatus} as the Pre-Event property:

Template code
{* condition: became-paid; the job's actions sit inside this branch *}
{$before = $event->getPreEventData()}
{$order = $event->getRecord()}
{if $order && $order->getFinancialStatus() == 'paid' && $before.financialStatus != 'paid'}
  {$result->setMatching(true)}
{/if}
{# condition: became-paid; the job's actions sit inside this branch #}
{% set before = event.getPreEventData() %}
{% set order = event.getRecord() %}
{% if order and order.getFinancialStatus() == 'paid' and before.financialStatus|default('') != 'paid' %}
  {% do result.setMatching(true) %}
{% endif %}

When the job must write the field it watches

Sometimes the field the job reacts to is the field it changes: tidying a title, normalising a code. Make the write conditional on it changing something. The first run writes the tidied value; its own save starts the job once more; the second run computes the same value, finds nothing to change, and stops:

Template code
{* condition: code-needs-normalising (products, Updated) *}
{$record = $event->getRecord()}
{$normal = strtoupper(trim($record->getCode()))}
{if $normal != $record->getCode()}{$result->setMatching(true)}{/if}
{# condition: code-needs-normalising (products, Updated) #}
{% set record = event.getRecord() %}
{% set normal = record.getCode()|trim|upper %}
{% if normal != record.getCode() %}{% do result.setMatching(true) %}{% endif %}

The job's own write moves the record's modification time, so a guard that compares it with a stored marker sees every run as new: use the watched field itself, as above. Step-by-step guards for each of the four loops are in Stop a job from triggering itself.

Test the guard before you publish

Run the job with a change to the watched field, then with a change to another field, then twice in a row. Only the first should act. Run with record sends no earlier values, so test guards with real edits on a test record (why).

Go deeper

Was this page helpful?

Your answer goes to the docs team.