Skip to main content

REST API in Practice: Stories and Patterns

The REST API component reference explains every setting on its own. This guide does the opposite: it walks through six realistic stories and shows why you would reach for a particular setting, and how the settings work together with Transform steps around them.

All systems, URLs and data in this guide are invented examples.

note

In every story, the REST API step uses the current component version. Under Advanced, Version should be 3.

Story 1 — Push an order to a warehouse system​

Acme Logistics runs the warehouse. When an order is released, you need to send it to their API and keep the shipment ID they return.

This is the build – call – unwrap shape, the most common integration pattern on the platform:

Build, call, unwrap: a trigger starts the workflow, a Transform builds the order request, a REST API step sends it to the warehouse system, and a second Transform unwraps the response.

Build: shape the request in a Transform​

Let the Transform produce exactly what Acme expects — and nothing that Acme doesn't need to see. A useful habit is to put the request under its own key, so the Transform can carry other values forward for later steps:

(
$order := $context.data.trigger.body;

{
"orderNumber": $order.orderNumber,
"request": {
"externalReference": $order.orderNumber,
"warehouse": $order.warehouse,
"lines": $order.lines.{
"sku": item,
"quantity": $number(quantity),
"unit": unit ? unit : "PCS"
}[]
}
}
)

Type conversion ($number(...)) and defaults (unit ? unit : "PCS") belong here, once, at the boundary — not scattered across later steps.

Call: a REST API step with no logic of its own​

SettingValue
Use connectionOn
ConnectionYour Acme Logistics connection
Endpoint URLhttps://api.acme-logistics.example/v1/shipments
HTTP Methodpost
Request body → Body pathrequest

Under Headers, add content-type = application/json.

Body path request sends only the request part of the Transform's output. If the build Transform's whole output is the request, use $ instead.

Prefer Body path over an inline Body

The REST API step also has a Body field, where you type the JSON with {{ }} placeholders. That's fine for one or two simple values. For anything more, build the body in a Transform and point Body path at it:

  • The JSON is always valid. A Transform produces real JSON. A typed body is text with values pasted in, so a value containing a " breaks it, and double braces HTML-encode characters such as &.
  • Types come out right. "quantity": "{{ body.qty }}" always sends text. A Transform sends a number when you give it a number.
  • Lists and logic are easy. Building an array of lines, applying a default or converting a date is ordinary JSONata in a Transform.
  • You can see what was sent. The Transform's output in the Execution Logs is exactly the request body, which makes a rejected request quick to debug.

Unwrap: turn the response into clean fields​

(
$res := $context.data.all.rest_api_1;

$res.statusCode >= 400
? {
"status": "FAILED",
"orderNumber": $context.data.all.transform_1.orderNumber,
"error": "Acme rejected order: " & $string($res.body.message)
}
: {
"status": "SENT",
"orderNumber": $context.data.all.transform_1.orderNumber,
"shipmentId": $res.body.data.shipment.id,
"carrier": $res.body.data.shipment.carrier.code
}
)

The build Transform is transform_1 and the REST API step is rest_api_1, so the unwrap Transform names both instead of relying on $. See How step IDs are formed.

Two things happen here:

  • The envelope is removed once. Acme nests the useful data under data.shipment; every later step sees your own flat field names instead.
  • The failure is handled where it happened. A failed request does not stop the workflow. The REST API step returns the error statusCode, with the error message in body. Without this check, the failure shows up later as a confusing mapping error. Instead, the Transform turns it into data: a "status" of "SENT" or "FAILED", plus the message that explains the failure. The check is >= 400 rather than >= 300 because the step follows redirects itself, so a 3xx never reaches the Transform.

Both branches return the same status field, so every later step can rely on it. A step that should only run for a successful order can skip itself with the Skip if expression body.status = "FAILED" (see Story 4), and a summary at the end of the run can list what failed and why (see Story 5).

note

A Transform can't stop the run on purpose. If its expression raises an error, including one from JSONata's $error(), the step passes its input through unchanged. Report failures as a status field like this instead.

Story 2 — Call an API that needs a login​

Your CRM, Northwind CRM, uses OAuth. You need to fetch a customer by number.

Credentials belong in a Connection, never in the workflow:

SettingValue
Use connectionOn
ConnectionYour Northwind CRM connection
Endpoint URLhttps://api.northwind-crm.example/customers/{{{ body.customerNumber }}}
HTTP Methodget

Why it matters:

  • An API key or token typed into Headers is visible to anyone who can open the workflow, doesn't get rotated, and is copied along when the workflow is copied.
  • With a Connection, pointing the workflow at a test system or production is a matter of choosing a different Connection.

Note the triple braces in Endpoint URL. Double braces HTML-encode the value — a customer number containing & would silently become & and break the URL.

See Connection authorization types for OAuth, Basic and API key set-up.

Story 3 — Fetch every page of a paged API​

The Acme product catalogue returns 100 products per page. You need all of them.

There is no "paging" setting on the REST API component — and you don't need one. The pattern is: ask for the first page, let a Transform build the list of remaining page requests, then let For each fetch them.

Paged fetch: a trigger starts the workflow, a REST API step fetches page 1, a Transform builds the list of remaining page requests, a REST API step with For each fetches them, and a final Transform merges the pages.

Step 1 — first page. A normal get to https://api.acme.example/products?page=1&pageSize=100. The response includes totalPages.

Step 2 — build the page list:

(
$firstPage := $context.data.all.rest_api_1.body;
$total := $firstPage.totalPages;

{
"firstPage": $firstPage.products,
"pages": $total > 1 ? [2..$total].{ "page": $ } : []
}
)

For 5 pages this produces "pages": [{ "page": 2 }, { "page": 3 }, { "page": 4 }, { "page": 5 }].

Step 3 — fetch the rest with For each:

SettingValue
Endpoint URLhttps://api.acme.example/products?page={{{ body.page }}}&pageSize=100
HTTP Methodget
For each (under Advanced)body.pages
Parallel executions3
Skip if expression$count(body.pages) = 0

Inside the loop, body means the current item, so {{{ body.page }}} is 2, then 3, and so on. Parallel executions fetches three pages at a time; keep the number modest unless you know the other system can handle more (the maximum is 20). The Skip if expression avoids a pointless call when everything fitted on page 1 — without it, an empty list would make a single normal request.

Step 4 — merge. The For each step, rest_api_2, returns a list with one { statusCode, body, headers } per page:

(
$first := $context.data.all.rest_api_1.body.products;
$pages := $context.data.all.transform_1.pages;
$results := $context.data.all.rest_api_2[];
$failed := [$map($results, function($r, $i) {
$r.statusCode >= 400 ? { "page": $pages[$i].page, "error": $string($r.body.message) }
})];

{
"status": $count($failed) > 0 ? "FAILED" : "OK",
"failedPages": $failed,
"products": $append($first, $results[statusCode < 400].body.products)
}
)

When a page fails, the result says so instead of quietly returning a shorter product list. status tells later steps whether the list is complete, and failedPages says which pages to look at. As in Story 1, a step that needs the complete catalogue can skip itself with body.status = "FAILED".

The same pattern works for any "one request per item" job: a Transform builds a list of exactly what each request needs, and For each sends them.

Story 4 — Don't call when there's nothing to send​

A scheduled workflow sends new stock movements to Acme every 15 minutes. Most runs, there are none.

Don't branch the workflow; let the REST API step skip itself. Set Skip if expression to:

$count(body.request.movements) = 0

When the expression is true, the step makes no request and returns statusCode 204 with an empty body. The unwrap Transform after it can treat 204 as "nothing to do":

(
$res := $context.data.all.rest_api_1;
$res.statusCode = 204 ? { "status": "NOTHING_TO_SEND" } : { "status": "SENT", "count": $count($res.body.accepted) }
)

Story 5 — Send one request per order, and report which ones failed​

A batch of 40 order confirmations must each be posted separately to a customer's portal.

Use For each on the list and a Body you build per item:

SettingValue
Endpoint URLhttps://portal.example.com/api/confirmations
HTTP Methodpost
For eachbody.orders
Parallel executions5

Request body → Body:

{
"orderNumber": "{{ body.orderNumber }}",
"confirmedDate": "{{ body.confirmedDate }}"
}

Two plain text values is about as far as an inline Body should go. If the confirmation needs more fields, numbers or a list of lines, build each request in the Transform that prepares orders and use Body path instead (see the tip in Story 1).

Each request has its own result, so one failure doesn't block the others. Results come back in the same order as the input list, which lets a Transform pair each result with the order it belongs to:

(
$orders := $context.data.all.transform_1.orders;
$results := $context.data.all.rest_api_1[];

{
"sent": $count($results[statusCode < 400]),
"failed": $map($results, function($r, $i) {
$r.statusCode >= 400 ? { "orderNumber": $orders[$i].orderNumber, "error": $r.body.message }
})
}
)

That summary is exactly what you want to email at the end of the run — see Email a Workflow Run Summary.

Story 6 — Download an invoice PDF​

For each invoiced order, fetch the PDF from the billing system so a later step can use the file.

SettingValue
Endpoint URLhttps://billing.example.com/invoices/{{{ body.invoiceNumber }}}/pdf
HTTP Methodget

Under Headers:

KeyValue
acceptapplication/pdf
content-dispositionattachment; filename="invoice-{{ body.invoiceNumber }}.pdf"

With attachment, only the saved file is passed on — the best choice for anything but small files. The file name is built from the data, so each invoice gets a recognisable name. See Working with files for the other content-disposition options.

Patterns at a glance​

You want to…Reach for
Build a request bodyA Transform before the REST API step, plus Body path. Use an inline Body only for one or two simple values
Send part of the Transform outputBody path request (or any field name)
Keep credentials out of the workflowUse connection + Connection
Put data into a URL safely{{{ }}} triple braces in Endpoint URL
Fetch all pagesFirst call → Transform builds page list → For each
Send one request per itemFor each + Parallel executions
Avoid empty callsSkip if expression
Catch failuresCheck statusCode in the Transform after the call and return "status": "FAILED" with the error message
Download a fileaccept + content-disposition: attachment headers

Further reading​