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 07 · Jobs and other systems

Worked integration: order fulfilment

One complete integration built from five jobs: a paid order pushed to a warehouse with an idempotency key, a retry every 15 minutes, shipping status pulled back hourly with a watermark, and a daily report of dead letters and reconciled counts.

Who this is for. Integration engineers who want a complete model to copy rather than an illustration. After this page you can build the same five jobs for your own fulfilment warehouse, and you know which parts of them keep it safe.

One complete integration, built only from jobs: a paid order is pushed to a fulfilment warehouse (a 3PL), the shipping status comes back on a schedule, and nothing is lost or sent twice when either side fails. It applies the rules of this unit: The integration pattern catalogue and its credentials, Retry, backoff and dead letters, and Field ownership and two-way sync. Copy it, change the parts that describe your 3PL, and keep the parts that make it safe.

The design

The 3PL's API, as this example assumes it; most offer the same three things under other names:

  • POST /v1/orders accepts an order. It honours an Idempotency-Key header: a second request with the same key creates nothing and answers 409.
  • GET /v1/orders?updatedSince=<ISO 8601> lists orders whose status changed since then, oldest first, each with the reference and siteOrderId it was sent, its status and its updatedAt.
  • GET /v1/orders?createdFrom=…&createdTo=… answers with a count, for reconciliation.

Who owns what.

FieldOwnerRule
Order lines, customer, delivery addressSiteSent once, at hand-off. The 3PL never sends them back
Fulfilment statusSite until hand-off, then 3PLThe site writes it exactly once, none to pending, when the 3PL has accepted the order. After that only the status pull writes it
The 3PL's own order id, tracking numbers3PLNot stored on the site in this example

Why the site's own API appears. Record actions cannot read or write orders. So wherever a job must load an order by id, list orders, or change one, it calls the site's REST API with an HTTP Request, as any other client would. That needs a backend user for the integration, "Fulfilment integration", with permission to view and edit orders and nothing else.

Which credential. OAuth is the preferred credential for a call into the site, but keeping an OAuth token fresh needs one process that refreshes it under a lock (Connect a server integration with OAuth and keep its token fresh). This example's five jobs run as independent runs with no shared process to do that, so it uses an API key on the "Fulfilment integration" user: the older credential, scoped to orders only (why a job cannot keep a token fresh).

Site data, set separately on every environment (Credentials for a third party):

KeyHolds
fulfilment-api-urlhttps://api.3pl.example/v1, the 3PL's sandbox on every environment but production
fulfilment-api-keyThe 3PL's key for this environment
site-api-keyThe API key of the "Fulfilment integration" user
integration-alerts-toThe shared inbox that reads the reports
fulfilment-status-watermarkWritten by the status pull; leave unset at first

Five jobs, in the category Integrations, all asynchronous, all with batch persistence always for the first month and errors after:

  1. Fulfilment: push order, started by the other jobs with an order id. The only job that sends an order.
  2. Order: paid → push to fulfilment, the event.
  3. Schedule: every 15 min → resend orders not handed off, the retry.
  4. Schedule: hourly → pull fulfilment status.
  5. Schedule: daily → fulfilment report, the dead letters and the reconciliation.

The push lives in one job that loads the order itself, so the event and the retry send exactly the same thing, built by the same template.

1. Fulfilment: push order

No trigger of its own; the Trigger job actions below start it, with event data {"orderId": 123}. Its actions, in order:

HTTP request get-order: GET, headers Authorization: Bearer {$siteData->get('site-api-key')}, retries 3, and a URL template written on one line, so the assignments leave no line break in front of the address. getPrimaryHostname() gives the primary hostname of the environment the job runs on, which is the right base for a call to the site's own API:

Template code
{$in = $event->getData()}{$fields = '{ id, uuid, number, financialStatus, fulfillmentStatus, items { productCode, name, quantity }, deliveryAddress { firstName, lastName, companyName, addressLine1, addressLine2, city, postcode, phone, countryName }, person { email } }'}https://{$site->getPrimaryHostname()}/cdna-api/orders/order/{intval($in.orderId)}?properties={urlencode($fields)}
{% set fields = '{ id, uuid, number, financialStatus, fulfillmentStatus, items { productCode, name, quantity }, deliveryAddress { firstName, lastName, companyName, addressLine1, addressLine2, city, postcode, phone, countryName }, person { email } }' %}https://{{ site.getPrimaryHostname() }}/cdna-api/orders/order/{{ event.getData().orderId|integer }}?properties={{ fields|url_encode }}

Because the request names its properties, the API answers with the order itself as data; without them it would be wrapped as data.order. A job reads the parsed answer as responseData, whose objects and lists are read-only collections: use them with . and foreach, and call toArray() where a plain array is needed.

Condition still-to-send, template type: carries on only for a paid order that has not been handed off. Everything below sits inside it; with no else, an order that is not ready is skipped.

Template code
{$order = $runData->get('get-order')->responseData.data}
{if $order.financialStatus == 'paid' && $order.fulfillmentStatus == 'none'}{$result->setMatching(true)}{/if}
{% set order = runData.get('get-order').responseData.data %}
{% if order.financialStatus == 'paid' and order.fulfillmentStatus == 'none' %}{% do result.setMatching(true) %}{% endif %}

Render template build-order, from templates/hooks/fulfilment/order.json.html: the payload, built as a value and encoded, so a quote in a customer's address cannot break it.

Template code
{* templates/hooks/fulfilment/order.json.html *}
{$order = $runData->get('get-order')->responseData.data}
{$address = $order.deliveryAddress}
{$lines = []}
{foreach $order.items as $item}
  {if strpos($item.productCode, 'SHIPPING') === false}
    {$lines[] = ['sku' => $item.productCode, 'description' => $item.name, 'quantity' => $item.quantity]}
  {/if}
{/foreach}
{$payload = [
  'reference'   => $order.uuid,
  'siteOrderId' => $order.id,
  'orderNumber' => $order.number,
  'email'       => $order.person.email,
  'shipTo'      => [
    'name'     => "{$address.firstName} {$address.lastName}",
    'company'  => $address.companyName,
    'line1'    => $address.addressLine1,
    'line2'    => $address.addressLine2,
    'city'     => $address.city,
    'postcode' => $address.postcode,
    'country'  => $address.countryName,
    'phone'    => $address.phone
  ],
  'lines'       => $lines
]}
{$payload|json_encode}
{# templates/hooks/fulfilment/order.json.html #}
{% set order = runData.get('get-order').responseData.data %}
{% set address = order.deliveryAddress %}
{% set lines = [] %}
{% for item in order.items %}
  {% if 'SHIPPING' not in item.productCode %}
    {% set lines = lines|merge([{'sku': item.productCode, 'description': item.name, 'quantity': item.quantity}]) %}
  {% endif %}
{% endfor %}
{{ {
  'reference': order.uuid,
  'siteOrderId': order.id,
  'orderNumber': order.number,
  'email': order.person.email,
  'shipTo': {
    'name': address.firstName ~ ' ' ~ address.lastName,
    'company': address.companyName,
    'line1': address.addressLine1,
    'line2': address.addressLine2,
    'city': address.city,
    'postcode': address.postcode,
    'country': address.countryName,
    'phone': address.phone
  },
  'lines': lines
}|json_encode }}

The shipping line is left out because a warehouse picks products, not postage; match it however your shipping is set up.

HTTP request send-order: POST to {$siteData->get('fulfilment-api-url')}/orders, timeout 30, retries 3, accepted status codes 2**,409, body {$runData->get('build-order')->content}, headers:

Template code
Authorization: Bearer {$siteData->get('fulfilment-api-key')}
Idempotency-Key: {$runData->get('get-order')->responseData.data.uuid}
Authorization: Bearer {{ siteData.get('fulfilment-api-key') }}
Idempotency-Key: {{ runData.get('get-order').responseData.data.uuid }}

The order's uuid is the idempotency key, so every attempt to send the same order, from any job, on any day, is the same request to the 3PL. A 409 means an earlier attempt got through and its answer was lost; accepting it as success is what lets the next step run.

HTTP request mark-handed-off: PUT to https://{$site->getPrimaryHostname()}/cdna-api/orders/order/{$runData->get('get-order')->responseData.data.id}, headers Authorization: Bearer {$siteData->get('site-api-key')}, retries 3, and a body built as a value, never typed as JSON (a {" in a Smarty field is read as a tag):

Template code
{$body = ['fulfillmentStatus' => 'pending']}
{$body|json_encode}
{{ {'fulfillmentStatus': 'pending'}|json_encode }}

This is the site's one write to the field, and the marker every other job reads: an order that is paid with fulfilment status none has not reached the 3PL.

If send-order fails after its retries, the run stops with an error before mark-handed-off, the order keeps status none, and job 3 sends it again. If mark-handed-off fails, the 3PL has the order but the site does not know; job 3 sends it again, gets 409, and marks it. Either way the next run finishes the job, which is the point of the design.

2. Order: paid → push to fulfilment

Trigger: entity event on orders, methods CreateRecord and UpdateRecord, Pre-Event property {financialStatus}. Two actions:

Condition became-paid:

Template code
{$before = $event->getPreEventData()}
{if $event->getRecord()->getFinancialStatus() == 'paid' && $before.financialStatus != 'paid'}{$result->setMatching(true)}{/if}
{% set before = event.getPreEventData() %}
{% if event.getRecord().getFinancialStatus() == 'paid' and before.financialStatus|default('') != 'paid' %}{% do result.setMatching(true) %}{% endif %}

Inside it, Trigger job: target Fulfilment: push order, event data:

Template code
{$data = ['orderId' => $event->getRecordId()]}
{$data|json_encode}
{{ {'orderId': event.getRecordId()}|json_encode }}

The PUT in job 1 is an update to the order and fires this job again. The condition is what stops the loop: the financial status did not change, so it does not match. This is the condition pattern from Guarding against recursion.

3. Schedule: every 15 min → resend orders not handed off

Schedule */15 * * * *, limited to the site. This is the scheduled retry and needs no state of its own: the orders that still need sending are exactly the paid orders whose fulfilment status is none.

HTTP request find-unsent: GET, the site API key as above, URL:

Template code
{$settled = date_create('-10 minutes')}{$oldest = date_create('-7 days')}https://{$site->getPrimaryHostname()}/cdna-api/orders/orders?financialStatus=paid&fulfillmentStatus=none&%3Cordered={urlencode($settled->format('c'))}&%3Eordered={urlencode($oldest->format('c'))}&orderBy=ordered&limit=100&properties={urlencode('{ id }')}
https://{{ site.getPrimaryHostname() }}/cdna-api/orders/orders?financialStatus=paid&fulfillmentStatus=none&%3Cordered={{ date('-10 minutes')|date('c')|url_encode }}&%3Eordered={{ date('-7 days')|date('c')|url_encode }}&orderBy=ordered&limit=100&properties={{ '{ id }'|url_encode }}

The ten-minute margin leaves a just-paid order to job 2, so the two do not race; the seven-day window is the "give up" of the retry policy. Dates go into the URL with their offset (format('c')), which the API honours; a bare date would be read in the site's timezone.

Loop resend: source Action, with Source action find-unsent and Property path data; sequential, wait done, stop on error off. The loop reads the stored answer of find-unsent at data, the list of orders, and needs no template of its own: each element is one order as the API returned it, so $loopRecord.id is its id. Inside it, Trigger job Fulfilment: push order with event data:

Template code
{$data = ['orderId' => intval($loopRecord.id)]}
{$data|json_encode}
{{ {'orderId': loopRecord.id|integer}|json_encode }}

Each resend is its own run of job 1, so one bad order fails alone, and its run in Batch Logs says why.

That gives the retry intervals from the policy: three quick retries inside each request, then a new attempt every 15 minutes, for seven days.

4. Schedule: hourly → pull fulfilment status

Schedule 5 * * * *. The watermark is the 3PL's own updatedAt, taken from its data with its offset, so the two systems' clocks and the site's timezone never meet.

HTTP request changed-orders: GET, the 3PL key, retries 3, URL:

Template code
{$since = $siteData->get('fulfilment-status-watermark')|default:'2026-01-01T00:00:00+00:00'}{$siteData->get('fulfilment-api-url')}/orders?updatedSince={urlencode($since)}&limit=200
{{ siteData.get('fulfilment-api-url') }}/orders?updatedSince={{ siteData.get('fulfilment-status-watermark')|default('2026-01-01T00:00:00+00:00')|url_encode }}&limit=200

Loop apply-status: source Action, with Source action changed-orders and Property path orders; sequential, wait success, stop on error on. Each element is one entry of the 3PL's orders list, with its siteOrderId, status and updatedAt.

Inside it, a Condition known-status that matches only statuses the mapping knows:

Template code
{if in_array($loopRecord.status, ['received', 'picking', 'part_shipped', 'shipped', 'delivered'])}{$result->setMatching(true)}{/if}
{% if loopRecord.status in ['received', 'picking', 'part_shipped', 'shipped', 'delivered'] %}{% do result.setMatching(true) %}{% endif %}

and in it an HTTP request set-status: PUT to https://{$site->getPrimaryHostname()}/cdna-api/orders/order/{intval($loopRecord.siteOrderId)} with the site API key and this body:

Template code
{$map = ['received' => 'pending', 'picking' => 'pending', 'part_shipped' => 'partial', 'shipped' => 'fulfilled', 'delivered' => 'completed']}
{$body = ['fulfillmentStatus' => $map[$loopRecord.status]]}
{$body|json_encode}
{% set map = {'received': 'pending', 'picking': 'pending', 'part_shipped': 'partial', 'shipped': 'fulfilled', 'delivered': 'completed'} %}
{{ {'fulfillmentStatus': map[loopRecord.status]}|json_encode }}

It sends only the field the 3PL owns. Writing a status the order already has changes nothing, so a repeated or overlapping pull is harmless. An unknown status is skipped rather than guessed, and shows in the run's log.

After the loop, a Render template move-watermark, which runs only when every element succeeded:

Template code
{$orders = $runData->get('changed-orders')->responseData.orders}
{$changed = $orders->toArray()}
{if $changed}{$last = end($changed)}{$siteData->set('fulfilment-status-watermark', $last.updatedAt)}{/if}
{% set changed = runData.get('changed-orders').responseData.orders %}
{% if changed|length > 0 %}{% do siteData.set('fulfilment-status-watermark', (changed|last).updatedAt) %}{% endif %}

The next run asks for changes since that moment. It gets the last order again (the 3PL's updatedSince is inclusive in this example), which the no-change write makes harmless, and a backlog of more than 200 drains 200 an hour.

5. Schedule: daily → fulfilment report

Schedule 0 8 * * *, in the client's timezone, so it arrives at the start of the working day. It does two things: lists the dead letters, and reconciles yesterday's counts.

  1. HTTP request stuck: the same query as job 3, but orders paid more than 24 hours and less than 30 days ago: those that exhausted a day of retries. Properties { id, number, ordered }.
  2. HTTP request site-count: /cdna-api/orders/orders?financialStatus=paid&%3Eordered=…&%3Cordered=…&limit=1 for yesterday, midnight to midnight in the site's timezone; the answer's totalCount is the number of orders paid.
  3. HTTP request 3pl-count: the 3PL's count for the same range.
  4. Condition needs-attention: matches when there are stuck orders or the counts differ.
  5. Inside it, Send mail to {$siteData->get('integration-alerts-to')}, subject Fulfilment: action needed, with the stuck orders' numbers and the two counts.

The condition, and the list for the body:

Template code
{$stuck = $runData->get('stuck')->responseData.data}
{$ours = $runData->get('site-count')->responseData.totalCount}
{$theirs = $runData->get('3pl-count')->responseData.count}
{if $stuck->count() > 0 || $ours != $theirs}{$result->setMatching(true)}{/if}
{% set stuck = runData.get('stuck').responseData.data %}
{% set ours = runData.get('site-count').responseData.totalCount %}
{% set theirs = runData.get('3pl-count').responseData.count %}
{% if stuck|length > 0 or ours != theirs %}{% do result.setMatching(true) %}{% endif %}
Template code
<!DOCTYPE html><html><body style="font-family:Arial,sans-serif;font-size:14px;">
{$stuck = $runData->get('stuck')->responseData.data}
<p>Paid yesterday: {$runData->get('site-count')->responseData.totalCount} on the site, {$runData->get('3pl-count')->responseData.count} at the warehouse.</p>
{if !$stuck->isEmpty()}
<p>Not handed off after 24 hours of retries:</p>
<ul>{foreach $stuck as $order}<li>Order {$order.number|escape}, paid {$order.ordered|escape}</li>{/foreach}</ul>
<p>Open each order's latest "Fulfilment: push order" batch in Batch Logs for the reason.</p>
{/if}
</body></html>
<!DOCTYPE html><html><body style="font-family:Arial,sans-serif;font-size:14px;">
{% set stuck = runData.get('stuck').responseData.data %}
<p>Paid yesterday: {{ runData.get('site-count').responseData.totalCount }} on the site, {{ runData.get('3pl-count').responseData.count }} at the warehouse.</p>
{% if stuck|length > 0 %}
<p>Not handed off after 24 hours of retries:</p>
<ul>{% for order in stuck %}<li>Order {{ order.number|escape }}, paid {{ order.ordered|escape }}</li>{% endfor %}</ul>
<p>Open each order's latest "Fulfilment: push order" batch in Batch Logs for the reason.</p>
{% endif %}
</body></html>

The report repeats every morning until the list is empty and the counts agree, so a dead letter cannot age out of view. An order older than 30 days drops off it; by then it is a support case, not an integration one.

Before it goes live

  1. On the development site, with the 3PL's sandbox in fulfilment-api-url, place a test order and pay it. Job 2 and job 1 each leave a successful run, and the order's fulfilment status is pending.
  2. Replay: re-run that job 1 run from Batch Logs. It is skipped at still-to-send. Set the order's status back to none and re-run it: the 3PL answers 409, the order is marked again, and the sandbox still has one order.
  3. Outage: point fulfilment-api-url at an address that does not answer, pay an order, and watch job 1 fail; restore the URL and watch job 3 send it within 15 minutes.
  4. Status: advance the order in the sandbox and check the site's status an hour later. Run job 4 twice by hand: the second run changes nothing.
  5. Report: run job 5 by hand with a stuck test order and read the email in the test inbox.
  6. Export the five jobs, import them to production, set the production site data, set the schedules and the site again (neither travels), publish, and watch the first real order through.

Put a heartbeat on jobs 3 and 4: a retry job that has stopped looks exactly like a quiet day.

Go deeper

Was this page helpful?

Your answer goes to the docs team.