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.
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: 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
| Setting | Value |
|---|---|
| Use connection | On |
| Connection | Your Acme Logistics connection |
| Endpoint URL | https://api.acme-logistics.example/v1/shipments |
| HTTP Method | post |
| Request body → Body path | request |
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.
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 inbody. 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>= 400rather than>= 300because the step follows redirects itself, so a3xxnever 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).
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:
| Setting | Value |
|---|---|
| Use connection | On |
| Connection | Your Northwind CRM connection |
| Endpoint URL | https://api.northwind-crm.example/customers/{{{ body.customerNumber }}} |
| HTTP Method | get |
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.
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:
| Setting | Value |
|---|---|
| Endpoint URL | https://api.acme.example/products?page={{{ body.page }}}&pageSize=100 |
| HTTP Method | get |
| For each (under Advanced) | body.pages |
| Parallel executions | 3 |
| 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:
| Setting | Value |
|---|---|
| Endpoint URL | https://portal.example.com/api/confirmations |
| HTTP Method | post |
| For each | body.orders |
| Parallel executions | 5 |
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.
| Setting | Value |
|---|---|
| Endpoint URL | https://billing.example.com/invoices/{{{ body.invoiceNumber }}}/pdf |
| HTTP Method | get |
Under Headers:
| Key | Value |
|---|---|
accept | application/pdf |
content-disposition | attachment; 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 body | A 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 output | Body path request (or any field name) |
| Keep credentials out of the workflow | Use connection + Connection |
| Put data into a URL safely | {{{ }}} triple braces in Endpoint URL |
| Fetch all pages | First call → Transform builds page list → For each |
| Send one request per item | For each + Parallel executions |
| Avoid empty calls | Skip if expression |
| Catch failures | Check statusCode in the Transform after the call and return "status": "FAILED" with the error message |
| Download a file | accept + content-disposition: attachment headers |
Further reading
- REST API component: the full settings reference
- Workflow Architecture 101: where the REST API step fits in a workflow
- JSONata Masterclass: write the build and unwrap Transforms well
- Connections: store logins for the systems you call
- Combine M3 API Calls: the same pattern against Infor M3
- Error Handling Deep Dive: define what success means for a run