How-to library · Developer docs

How-to library

Task-shaped pages: a title that starts with a verb, the situation in a few lines, the steps, the working code in both engines, and why this way rather than another. Found from a search, done, and left.

Post a record to an external API when it changes

An UpdateRecord job with Pre-Event properties, a condition that passes only real changes, a payload built as a value, and an idempotent PUT to the other system with retries.

Context

Context. Another system keeps a copy of some records: a search service, a PIM, a partner's catalogue. When a product changes on the site, send the new version. The job has to send only real changes, send the same thing no matter how many times it runs, and fail loudly when the other side refuses it.

Steps

  1. Create a job Product: changed → update catalogue, asynchronous, limited to the site.
  2. Trigger: entity event, products, method UpdateRecord, with the Pre-Event properties {name, code, published}, the fields the other system cares about.
  3. First action, a template Condition relevant-change that matches only when one of those fields changed. A save that touched nothing it cares about ends here, skipped.
  4. Inside it, a Render template build-payload that builds the body as a value and encodes it.
  5. Then an HTTP request put-product: PUT to the other system's URL for this product, the key from site data, retries 3, timeout 30.
  6. Publish, edit a product's name, and read the batch: the request's output shows the URL, the body sent and the response.

Code

Template code
{* condition: relevant-change *}
{$before = $event->getPreEventData()}
{$product = $event->getRecord()}
{$publishedNow = $product->getPublished() !== null}
{$publishedBefore = !empty($before.published)}
{if $before.name != $product->getName() || $before.code != $product->getCode() || $publishedBefore != $publishedNow}{$result->setMatching(true)}{/if}
{# condition: relevant-change #}
{% set before = event.getPreEventData() %}
{% set product = event.getRecord() %}
{% if before.name != product.getName() or before.code != product.getCode() or (before.published is not empty) != (product.getPublished() is not null) %}{% do result.setMatching(true) %}{% endif %}
Template code
{* render template: build-payload *}
{$product = $event->getRecord()}
{$payload = [
  'id'        => "site-product:{$product->getId()}",
  'code'      => $product->getCode(),
  'name'      => $product->getName(),
  'url'       => "https://{$site->getHostName()}{$product->getFullPath()}",
  'published' => $product->getPublished() !== null
]}
{$payload|json_encode}
{# render template: build-payload #}
{% set product = event.getRecord() %}
{{ {
  'id': 'site-product:' ~ product.getId(),
  'code': product.getCode(),
  'name': product.getName(),
  'url': 'https://' ~ site.getHostName() ~ product.getFullPath(),
  'published': product.getPublished() is not null
}|json_encode }}

The request: URL https://api.catalogue.example/v1/products/site-product:{$event->getRecordId()}, header Authorization: Bearer {$siteData->get('catalogue-api-key')}, body {$runData->get('build-payload')->content}. The Content-Type of JSON is added for you.

Why this and not that

PUT to the record's own address, not POST to a collection. A PUT of the whole record is idempotent: sending it twice leaves the other system as sending it once, so a retry, a re-run from Batch Logs or two saves in a row are harmless. A POST that creates would make a duplicate each time. The id in the URL is yours, prefixed, so the other system never needs to tell you its own.

A condition on the Pre-Event data, not a job that sends on every save. Editors save often, and every UpdateRecord fires the job; without the guard each one is a request. It also stops this job reacting to its own side effects if it ever writes the product back.

Retries 3, and the default accepted codes. A refusal (a 4xx) fails the batch with the response in its log, which is what you want: a silent drop is worse than a visible failure. For outages longer than the retries, and for the rest of the rules (credentials, ownership, dead letters), see Integrating with external systems. A delete needs its own job on DeleteRecord, with {code} captured as a Pre-Event property, because the record is gone by the time the batch runs.

Go deeper

Was this page helpful?

Your answer goes to the docs team.