Email a Summary at the End of a Workflow
The problem
A workflow that runs on a schedule or reacts to events often works unseen. When something goes wrong, for example three orders out of fifty rejected by the receiving system, nobody notices until a customer calls. Checking the Execution Logs every morning isn't a real process.
The solution
End the workflow with two steps:
- A Transform step that looks back at what happened during the run and computes a small, clean summary: how many items were processed, how many succeeded, and which ones failed and why.
- An Email step that turns that summary into a readable message using placeholders in the Subject and Body.
The Transform does the thinking and the Email only formats. Keeping them apart makes both easy to change: you can adjust what counts as a failure without touching the email layout, and the other way round.
What you'll build
A scheduled workflow sends a batch of orders to an external system with a REST API step that uses For each, one request per order. At the end, the team receives an email like this:
Subject: ⚠ 1 of 3 orders failed – batch B-2026-10-02-01
Batch B-2026-10-02-01 finished at 2026-10-02 06:00. 2 of 3 orders were sent successfully.
Order Status code Reason 1000125 422 Unknown item WASHER-M9
When everything succeeds, the subject reads ✅ All 3 orders sent – batch B-2026-10-02-01 instead.
Prerequisites
- A workflow whose main work is done. This guide uses a REST API step with For each, but the same approach works after any step.
- Familiarity with the Transform component. The JSONata Masterclass covers the techniques used below.
Step 1 — Know what the earlier steps return
The summary is built from the output of earlier steps, so first look at what they return. Run the workflow once and open the Execution Logs.
A REST API step with For each returns one result per item, each with its own statusCode, body and headers:
[
{ "statusCode": 200, "body": { "orderNumber": "1000123", "status": "accepted" }, "headers": {} },
{ "statusCode": 200, "body": { "orderNumber": "1000124", "status": "accepted" }, "headers": {} },
{ "statusCode": 422, "body": { "orderNumber": "1000125", "error": "Unknown item WASHER-M9" }, "headers": {} }
]
A failed request does not stop the workflow. The REST API step records the error status code and continues. That's exactly what you want here: the summary step still runs and can report the failures.
Step 2 — Build the summary with a Transform
Add a Transform step after the REST API step. It reaches back to the REST API step's output with $context.data.all.<step id>, and to the data the workflow started with through $context.data.trigger.body:
(
$calls := $context.data.all.rest_api_1[];
$ok := $calls[statusCode < 400];
$failed := $calls[statusCode >= 400];
{
"batchId": $context.data.trigger.body.batchId,
"runAt": $fromMillis($millis(), "[Y0001]-[M01]-[D01] [H01]:[m01]"),
"total": $count($calls),
"succeeded": $count($ok),
"failedCount": $count($failed),
"hasFailures": $count($failed) > 0,
"failed": $failed.{
"orderNumber": body.orderNumber,
"statusCode": statusCode,
"reason": body.error ? body.error : "No error message returned"
}[]
}
)
Replace rest_api_1 with the ID of your own REST API step. A few details matter:
[]after the step reference and afterfailed: these keep the values as lists even when there is only one call or one failure. Otherwise the email's list loop would receive a single object. See Always get an array.hasFailuresas its own field: the email template can test a simple true/false value. That keeps all the logic in the Transform.runAt:$millis()returns the current time in UTC.
The output is the summary the email works with:
{
"batchId": "B-2026-10-02-01",
"runAt": "2026-10-02 06:00",
"total": 3,
"succeeded": 2,
"failedCount": 1,
"hasFailures": true,
"failed": [
{ "orderNumber": "1000125", "statusCode": 422, "reason": "Unknown item WASHER-M9" }
]
}
Decide in the Transform what counts as a failure. If the receiving system answers 200 but writes "status": "rejected" in the body, filter on that instead: $calls[body.status = "rejected"].
Step 3 — Configure the Email step
Add an Email step directly after the Transform. In the Subject and Body, placeholders in double curly braces are filled in from the previous step's output (here, the summary), available as body.
| Setting | Value |
|---|---|
| To | The team's distribution list |
| Subject | See below |
| HTML | On |
| Body | See below |
Subject, with a different text depending on the outcome:
{{#if body.hasFailures}}⚠ {{body.failedCount}} of {{body.total}} orders failed{{else}}✅ All {{body.total}} orders sent{{/if}} – batch {{body.batchId}}
Body (HTML):
<p>Batch <b>{{ body.batchId }}</b> finished at {{ body.runAt }}.</p>
<p>{{ body.succeeded }} of {{ body.total }} orders were sent successfully.</p>
{{#if body.hasFailures}}
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Order</th><th>Status code</th><th>Reason</th></tr>
{{#each body.failed}}
<tr><td>{{ orderNumber }}</td><td>{{ statusCode }}</td><td>{{ reason }}</td></tr>
{{/each}}
</table>
{{else}}
<p>No action needed.</p>
{{/if}}
The Subject is mandatory. An Email step without a subject fails.
Placeholder syntax at a glance
| Write | What it does |
|---|---|
{{ body.total }} | Inserts a value from the previous step's output. Special characters such as < and & are escaped, so the value is shown as text. |
{{{ body.htmlTable }}} | Inserts the value as-is, without escaping. Use it only for HTML you built yourself in the Transform. |
{{#each body.failed}} … {{/each}} | Repeats the content once for every item in a list. Inside the loop, refer to the item's fields directly: {{ orderNumber }}, not {{ body.failed.orderNumber }}. |
{{#if body.hasFailures}} … {{else}} … {{/if}} | Shows the first part when the value is true (or a non-empty value), otherwise the part after {{else}}. |
Keep the template simple. Calculations, filtering and formatting such as dates and rounding belong in the Transform, where you can test them. The template should only place finished values.
Variations
- Report on any step: the same pattern works after a Table Updater, a Data Lake query or an M3 API step. Point the Transform at that step's output and count what matters: rows written, rows returned, records rejected.
- Totals instead of failures: for a daily report, compute sums and averages in the Transform (
$sum,$round) and show them in the body, for example "1,240 lines picked across 3 warehouses". - Plain text: turn HTML off and write the body as plain lines.
{{#each}}and{{#if}}work the same way. - Attach the details: keep the email short and attach the full output with Attachments and Attachment Source, so the recipient has everything without the body becoming a wall of data.
Further reading
- Email component: reference for all Email fields
- Templating Reference: every
{{ }}placeholder and helper you can use in Subject and Body - Transform component: reference for the Transform step
- JSONata Masterclass: structuring the summary expression
- REST API in Practice: For each, status codes and failure handling
- Error Handling Deep Dive: make runs findable and define what "success" means in the Execution Logs
- Execution Logs: inspect what each step returned