Building Workflows
The Orchestrator is a flow designer: a vertical spine of steps that runs top to bottom, a palette of every action on the left, a properties panel on the right, and the workflow's source, run log and problems in a drawer underneath. Build it once, publish it, and one HTTPS call runs the whole thing.
New to the product rather than to this page? Getting Started walks the whole path — sign in, connect a system, build, run, publish — without any of the source below.
The workbench
Five areas, and nothing hidden behind a mode:
Palette (left)
Every action, grouped the way the API reference groups them, with a search box (press / from anywhere) and your recently used and starred actions pinned above. Each row shows what that action costs. Drag one onto the canvas, or click it after clicking a +.
Canvas (center)
The workflow itself, laid out for you — you never place a card by hand. An intake at the top says what feeds this workflow; a card per step; a result terminator at the bottom saying which step's output the caller gets back. Every connector carries a + insert point, and the three structural blocks — a fan-out, a condition and a loop — draw as group boxes with the steps inside them. See lanes, conditions and loops.
Inspector (right)
Opens when you select a card and shows that step's parameters, its label, what happens if it fails, and whether it is the result step. This is where you bind one step's output to the next step's input — see below.
Drawer (bottom)
Source is the workflow as text — the canvas and the text are the same object, so an edit in either shows up in the other immediately. Run is the log of the last run, one entry per step, with the response each one returned. Problems lists every error and warning; click one to jump to the step it belongs to.
The command bar across the top carries the workflow name and its state on the left, and New / Load / Save / Publish / Run on the right. Run always shows what the run will cost before you click it.
Build one
- Add the first step. Drag an action from the palette onto the canvas, or click the + under the intake and pick one. The card lands on the spine with its required parameters already listed.
- Chain the next one. Drop an action onto an existing card, or on the + below it, and it is inserted after that card with its document input already bound to the card above — a PDF step dropped under a step that produces a PDF is wired for you. Nothing you have already set is overwritten.
- Fan out when one document has more than one destination. Drag Run in parallel from the top of the palette; each lane runs against the step above the split, not against each other, and a lane can hold several steps. The two other structural blocks live beside it: Condition for a yes/no fork and For each to repeat over a list. See Repeat, split and decide below.
- Send it somewhere. The dashed Choose a destination card opens a list of everything the platform can deliver to, grouped as rows, files, messages and work items — see choosing a destination. Picking one turns the card into a real step with its settings seeded and the step above already wired in.
- Run it. The cards light up in order and the Run tab fills in as each one answers. Nothing is billed for a failed run, and using the builder itself is free — only published webhook calls and direct API calls are metered.
- Publish it to get a webhook URL. See Webhooks & Integrations.
What starts a workflow
The card at the top of the canvas is the intake. Click it to see everything that can set this workflow going, and to attach one. A workflow can have several.
| Intake | What it is |
|---|---|
| Webhook / API call | The published workflow's own address. Always available once it is published;
the body you post arrives as $input. See
Webhooks & Integrations. |
| Web form | A form we host. Each submission calls the workflow, with the answers as
$input keyed by your own field names. |
| Schedule | A clock. Nothing has to call it. See a schedule. |
| Folder watcher | A folder of yours that we look at on a cadence. Each new or changed file starts
the workflow once, with the file's name and path as $input. Works on
a SharePoint, SFTP, Dropbox,
Google Drive, OneDrive,
Azure Blob or Amazon S3 connection. See
watch a folder. |
| Table watcher | A table of yours that we look at on a cadence. Each new row, or each new or
changed row, starts the workflow once, with the row as $input.
Works on a Dataverse, SQL Server,
PostgreSQL or MySQL connection. See
watch a table. |
| Query | A Dataverse or SQL query step in this workflow, promoted to be what feeds it. See a query. |
A schedule: run it on a clock
Publish the workflow, open the intake, and type a cadence. Plain words work —
every 15 minutes, every 2 hours, hourly,
daily at 06:00, weekly on Monday at 06:00,
monthly on the 1st at 06:00 — and so does a five-field cron line such as
0 6 * * *. Times are UTC, everywhere, and the intake says so
rather than guessing at your time zone.
The chip on the intake then reads the cadence and when it next runs. You can pause a schedule, resume it, run it once by hand, or delete it, all from the same panel.
- A schedule calls the published address, so a run costs exactly what the same call would cost from anywhere else, appears in Usage the same way, and is refused the same way when the balance is short. Having a schedule costs nothing; a schedule that is not due costs nothing.
- It never catches up. If the service was asleep for two hours, an every-five-minutes schedule runs once when it wakes, not twenty-four times.
- It pauses itself when it is clearly broken. Five failures in a row suspends it, with the reason kept on the schedule. A workflow that is no longer published suspends it on the first attempt, because that will not fix itself. Being short of tokens never suspends it, because that does fix itself.
A query: rows as the input
When a workflow is really "read these rows and do something with each of them", make the
query itself the intake. Pin a Dataverse or SQL query step as the intake and its rows
become $input for everything after it — so the rest of the workflow reads
$input.rows, the same name a form-fed workflow uses, and a loop can iterate it.
(pipeline "Example: Nightly Account Report"
:intake "read-accounts"
(step "read-accounts"
:label "Query the accounts"
(action "DataverseQueryRows"
:connectionId "REPLACE_WITH_CONNECTION_ID"
:entitySet "accounts"
:select "name,address1_city,telephone1"))
(step "render"
:label "Fill the report template"
(action "ComposeFill"
:templateId "REPLACE_WITH_TEMPLATE_ID"
:data $input.rows
:outputFormat "pdf"
:filename "Nightly report")))
Such a workflow accepts an empty call: there is nothing to post, because the workflow fetches its own input. That is exactly what a schedule sends it. The query is an ordinary step of the workflow and is billed once, like any other.
Each row once. A schedule that runs such a workflow reads the same rows
every time unless it remembers where the last run got to. When you add the schedule, name
the column that moves when a row changes (modifiedon, a row version, an
identity) and choose what the first run does with the rows already there: skip them, or
deliver them all. After that, each run reads only the rows whose column is above the
highest value the last successful run saw. The schedule sends that starting point on its
own calls, in the X-DBF-Intake-Watermark header, and only the schedule runner
may send it: a call made with your API key that sends it is refused with
403 WATERMARK_NOT_PERMITTED before anything is set aside,
because the starting point decides which rows a run sees.
Watch a folder or a table
A watcher looks at one place of yours on a cadence and runs a published workflow once for every new or changed file it finds there, or for every new or changed row. Nothing has to call the workflow: the watcher is the caller.
Setting one up
- Click the intake at the top of the canvas and choose Watch a folder or Watch a table.
- Pick the connection. The list holds every connection of yours that can be watched, each labeled with what it is. If the one you want is missing, add it under Connections first.
- Say where to look (the settings for each source are below), how often to look in whole minutes (1 to 1,440, or 5 if you leave it empty), and a name if you want one.
- Read the price line under the form, then click Start watching. A watcher delivers to the published workflow, so one that is not published yet is published first.
Before the watcher is saved it is checked: the connection answers, the folder or table is there, and the workflow is published and yours. A mistake is refused there and then, with the reason, rather than found on the first look.
The intake then shows the watcher as a chip, with Pause, Resume, Poll now (look now instead of at the next scheduled time) and Delete beside it. Watchers & Batch in the portal lists every watcher you own, whatever it watches, and can start one for any published workflow. To point a watcher somewhere else, delete it and start a new one.
Watch a folder
| Connection | Where it looks | The workflow's first step |
|---|---|---|
| SharePoint | A site address, a document library and, if you like, a folder in it (empty watches the whole library). It follows the library's own change list, so files in the folders inside the watched one count too. | SharePoint: Get File with $input.file.itemId,
on the same connection (and $input.file.driveId when the library
is not the connection's own). |
| SFTP | A folder, relative to the folder the connection already points at (empty watches that folder itself), and an optional file-name pattern. | SFTP: Download File with $input.connectionId
and $input.file.path. |
| Dropbox, Google Drive, OneDrive | Nothing to choose: the DocButterfly folder the connection was granted, which is the only folder it can reach. | Storage: Download File with $input.connectionId
and $input.file.id. |
| Azure Blob | A container (empty uses the connection's own), a prefix naming the folder (empty watches the top level of the container), and an optional file-name pattern. | Blob: Read File with $input.file.path as the path
and $input.container as the container. |
| Amazon S3 | A bucket (empty uses the connection's own), a prefix naming the folder (empty watches the top level of the bucket), and an optional file-name pattern. Any S3-compatible store the connection serves is watched the same way. | S3: Get Object with $input.file.path as the key
and $input.bucket as the bucket. |
The workflow receives the file's details, never its bytes: $input.file holds
its name, path, size and modified time, which is exactly what the
first step above needs to fetch it.
File names. On SFTP, Azure Blob and Amazon S3 a watcher can take a
file-name pattern such as *.zip or job-????.csv: *
stands for any run of characters and ? for exactly one. It is matched against
the name only, never the path, and capital letters do not matter. A file whose name does not
match is not delivered and not billed. Leave it empty to watch every file.
One folder, not the ones inside it. SFTP, Azure Blob, Amazon S3 and the Dropbox, Google Drive and OneDrive folder are read one level deep: files only, and nothing from the folders inside the watched one. That is what lets a workflow write its results into a folder beside the file it was handed without being handed them back. SharePoint is the exception, because it follows the library's change list: a SharePoint workflow should write its output outside the watched folder.
A folder per file: baseName and jobPath
SFTP, Azure Blob and Amazon S3 watchers hand the workflow two more fields, made for job workflows, so that no step has to take a file name apart:
$input.file.baseNameis the file's name without its last extension:acme-42.zipgivesacme-42. A name with no extension, or one that starts with a dot, is kept whole.$input.file.jobPathis the folder named after the file, beside it in the watched folder:inbound/acme-42.zipgivesinbound/acme-42.
A job workflow writes what it makes under <jobPath>/output/ and, on SFTP,
moves the file it was handed into <jobPath>/input/. Neither comes back to
it, because the watcher reads only the watched folder itself. The
AI job below is built this way.
Watch a table
A table watcher remembers how far it has read and, at each look, reads the rows that come after that point, in order. It needs nothing on your database beyond the read access a query connection already has: no change data capture, replication or change tracking to switch on.
| Setting | What to put there |
|---|---|
| Table | On Dataverse, the table's entity set name, such as accounts. On SQL
Server, PostgreSQL and MySQL, the table, with its schema when it has one, such
as dbo.orders. |
| Column that moves when a row arrives or changes | A column whose value only ever goes up: a created or modified time, or an id
that increases. On Dataverse, createdon for new rows and
modifiedon for new and changed rows. |
| Deliver | New rows only, or New and changed rows. For the second, the column above should be a modified time: a row edited again moves past the watcher's mark again, and is delivered again. |
| Key column | The column that names a row, normally its primary key. It settles ties: when many rows share one time, the watcher walks them in key order, so none is skipped and none is delivered twice. On Dataverse it defaults to the table's own id column. On SQL it is needed when the column above is a date or time; an increasing number can be its own key. |
Each row arrives whole as $input.row, every column of it; its position is in
$input.cursor, and $input.table, $input.mode and
$input.connectionId say where it came from. A row is its own content, so there
is nothing to fetch: the first step can use $input.row.email directly.
- A deleted row is never delivered. There is no row left to read.
- The column has to keep going up. A row written with an earlier time than the last one delivered, or with an id that was used before, is behind the mark and is not seen.
- Times are compared a whole second at a time. Databases keep finer times than they hand back, so the watcher works in whole seconds and walks each second in key order. On Dataverse a second is read once it is five seconds old, so a row can arrive a few seconds after it was written.
- A row larger than 256 KB stops the watcher, with the reason, rather than being delivered cut short.
What a watcher delivers, and when
- The first look delivers nothing. It records where things stand (the files in the folder, or the table's newest row) and starts no runs, so pointing a watcher at a folder of ten thousand files or a table of a million rows reprocesses none of them. What arrives or changes after that is what the workflow receives. A watcher you delete and start again begins with a first look too.
- New and changed, once each. A folder watcher remembers each file's name (on Dropbox, Google Drive and OneDrive, its id), size and modified time, and on Azure Blob and Amazon S3 its ETag, so a file replaced under the same name is delivered again as changed. A deleted file is forgotten and never delivered.
- Thirty at a time. One look delivers at most 30 files or rows; the rest follow at the next looks.
- Nothing is skipped when a delivery fails. If the workflow cannot be started for a file, or its run fails, the watcher keeps its place, and the next look finds the same files again, including any delivered alongside the one that failed. A failing workflow can therefore be handed a file more than once; a file is never silently dropped.
- A watcher that cannot work stops, with the reason on it. The workflow was unpublished, the connection was removed or its credential was refused, the folder or table is gone, or the folder is over its limit (below). Fix the cause and click Resume. A server that is briefly out of reach, or a balance that is briefly short, is simply tried again at the next look.
How big a watched folder can be
SFTP, Azure Blob, Amazon S3 and the Dropbox, Google Drive and OneDrive folder are read whole at every look, so a watched folder may hold at most 500 entries. On SFTP, Azure Blob and Amazon S3 the folders inside it count toward that, and so does every file the name pattern leaves out. Past 500 the watcher stops with the reason rather than quietly watching part of the folder: have the workflow move finished files out, or point the watcher at a narrower folder. SharePoint reads the library's change list rather than the folder, and a table watcher reads only what is past its mark, so neither has this limit.
What a watcher costs
1 token for each file or row a watcher hands to your workflow, and nothing for a look that finds nothing, however often it looks. A watcher on a quiet folder costs nothing while it waits, and the first look, which delivers nothing, is free.
That charge is for the delivery. The run it starts is billed like any other run of the workflow, on the same account: its steps cost what they cost. The watcher form states the price per file or row before you click Start watching, read from the same catalog the bill is computed from. A look you ask for yourself through the API is billed the same way, and costs at least one delivery's price even when it finds nothing, because that look was asked for.
A workflow whose runs fail is started again at every look (see above). Each look charges the delivery again and bills the steps that run again, except a destination write that already landed on an earlier look: that one is handed back rather than written twice, and costs nothing (see what a published run answers). Pause a watcher while you fix its workflow.
An AI job from a watched folder
Someone puts a zip on your SFTP server: the documents, plus an instructions file saying
what to do with them. A workflow picks it up, hands it to AI, files the results in a
folder for that job, and emails that the job is ready to pick up. The
Send to AI step does the middle part: the model reads the instructions
and the documents, may call the DocButterfly actions you allow (convert, merge, split,
fill, and so on), and answers with one output.zip.
The folder layout
The zip's name is the job's name. For a watched folder inbound and a zip
called acme-42.zip:
inbound/acme-42.zip what the customer drops instructions.md or instructions.txt, or instructions.docx contract.docx, invoice.pdf, ... the documents inbound/acme-42/input/acme-42.zip the zip as it arrived: the originals inbound/acme-42/output/output.zip the originals, everything produced, and job-report.md
The job folder is named for the zip and sits beside it in the watched folder; the
watcher hands it to the workflow ready-made as $input.file.jobPath (see
a folder per file). A
person who signs in to the SFTP server finds the whole job in one place, and the zip
itself leaves the watched folder once the job has run. job-report.md says what the AI did, file by
file, and what it called along the way.
The workflow
Open Examples in the Orchestrator and pick AI Job from a Watched SFTP Folder: it lays out every step below with its inputs already wired to what the watcher sends. Set the address on the email step, publish it, and point a watcher at the folder.
| Step | What it does |
|---|---|
| SFTP: Download File | Reads the zip the watcher named: $input.connectionId and
$input.file.path. |
| Send to AI | Gets the zip's bytes, follows the instructions, and answers with
outputZipBase64, a manifest of what it produced, and what it used. |
| SFTP: Upload File | Puts output.zip in <job>/output/, creating the
folders as it goes. |
| SFTP: Move File | Moves the zip itself into <job>/input/, which empties
the watched folder of it. |
| Send Email | Tells the address you set that the job is ready to pick up. |
The workflow language has no way to join two pieces of text, so the example builds the two paths under the job folder with two small Concatenate Text steps. They are ordinary steps and are billed like any other.
On the watcher, set the name pattern to *.zip so only job zips start the
workflow. Nothing the job writes is delivered back to it: an SFTP watcher reads the files
in the one folder it is pointed at, never the folders inside it. Each job folder does
count toward the 500 entries a watched folder may hold (see
how big a watched folder can be), so move finished jobs
elsewhere from time to time.
An Azure Blob or Amazon S3 watcher hands over the same $input.file.jobPath,
so the same layout works there with the Blob or S3 read and write steps in place of the
SFTP ones. Neither has a move step, so the zip stays where it was dropped; it is not
delivered again unless it changes.
Our model, or your own Azure OpenAI
By default the step runs on the Azure OpenAI deployment DocButterfly hosts. To run it on your own instead, add an Azure OpenAI connection under Connections (the resource's endpoint, the deployment name, and an API key) and pick it in the step's AI model box. The documents a job sends and the model's own bill then stay in your tenant, and the step bills at the own-model rate. Test on the connection checks the key and the deployment before any job relies on them.
How a job is billed
- The model is billed by use. One unit is 10,000 model tokens, counted across every call the model makes in the job and rounded up, with a minimum of one unit. A model token is the unit the AI counts text in, about four characters of English; everything it reads and everything it writes counts.
- The actions it calls bill their own cost, the same as they would as a step of their own.
- A job has a ceiling, and never passes it. A job reserves 30 units at the step's rate before it starts. The model's units and every action it calls both come out of that reserve, and the job stops before it would go over. Whatever it did not use is not billed.
- The rates are on the step itself. Select the Send to AI step in the Orchestrator: its cost line states the rate per unit for our model and for your own, and the reserve for the model you picked, read from the same catalog the bill is computed from. The step's model-token cap (100,000 unless you change it, at most 300,000) limits the model's share further.
Choosing a destination
A workflow minted from a web form — or a lane you have just added — starts with a dashed Choose a destination card. Opening it lists everything the platform delivers to, in five families:
| Family | Destinations | What it wants |
|---|---|---|
| Rows | Dataverse, SQL, SharePoint | The submission or an earlier step's rows, written as records. |
| Files | Azure Blob storage, S3, SFTP | The document the step above produced, plus a path to put it at. |
| Messages | Email, Azure Service Bus | A subject and a body, or one message; a document goes as an attachment rather than into the body. |
| Work items | Azure DevOps | A project and a work item type; the step above becomes the description. |
| APIs | Any REST API, through a saved REST connection | A path under the connection's address, or none to post to the address itself; the submission or the step above is sent as the JSON body. |
Each row shows what it costs before you click it, and the connection dropdown beside it only offers connections of the right type — naming the type it needs when you have none. Email has no dropdown at all: it goes out through the platform's own mail registration.
Picking one replaces the placeholder with a real step, seeds the settings that have an obvious answer, and wires the step above into it. Anything it cannot know — which project, which path — it asks for.
Building a query without typing syntax
A Dataverse or SQL query step carries two buttons above its settings, and both open as soon as you have picked a connection:
- Browse data shows the tables on that connection and the columns on a table, and fills in the table and the columns you tick.
- Build query is the filter, the sort and the page size, as boxes: column, operator, value, one row per condition, with a single and/or for the set. Columns are listed under both names — the friendly one you recognize and the logical one the query actually sends — and what will run is printed beside the form so you can see it.
There is nothing to learn and no query language to type; the filter travels as structured values, so no text of yours is ever spliced into a query. Two things it is strict about, and both say so on screen: a value has to match the type you declared for it, and a filter it cannot draw (a bracketed group inside a group) is refused rather than flattened — flattening one silently changes which rows you get.
Once a query step has run, its own columns are offered by name to every step below it, so the next step's settings can be picked rather than typed.
Using a step's output in a later step
This is the one thing worth reading before you build anything. A step refers to an earlier step's result by step id and field name:
(step "stamp"
(action "WatermarkPdf"
:pdf (output-of "convert" "pdf") ; the "pdf" field of the step whose id is "convert"
:watermark.text "CONFIDENTIAL"))
Pick it, do not type it
Select the step, find the parameter in the inspector, and choose the earlier step and the field from the list rather than typing the reference by hand. The list is built from what those steps actually produced the last time you ran the workflow, so it cannot name a field that does not exist. Typing the reference works too, and the Source tab always shows you exactly what you built — but a picked reference is a reference you cannot misspell.
Two response shapes, and how to tell which one you have
result object, and then the field path has to say so.
In one pipeline, both of these can be correct:
(output-of "read-the-file" "contentBase64") ; results at the top level
(output-of "update-the-zip" "result.zip") ; results nested under `result`
The reliable way to find out: run the step once and read what came back.
- Put the step on the canvas and click Run.
- Open the Run tab in the drawer and expand Raw response under that step.
- The keys you see there are the field names you write. If they sit inside
result, writeresult.<field>.
A step usually costs one token to run, and the beta's monthly allowance is generous enough that checking is cheap. It is certainly cheaper than the alternative below.
What a wrong path looks like
Nothing complains while you build. Publishing refuses one kind of wrong path, a field read
at the wrong level (it sits under result, or the path says
result and the field is at the top); any other field is only checked when the
workflow runs. In the builder, the step turns red and the Run tab names the field it
could not find. Through a published webhook you get an HTTP 500 with
PIPELINE_ERROR, and its message names the step and the field. If you get one of
those, open the workflow, click Run, and the builder will tell you which step and which
field. Every other answer a run can give is listed under
what a published run answers.
Repeat, split and decide
A workflow is a spine of steps that runs top to bottom. Three things break that straight line, and all three are drawn the same way — a marker, a group box, and a join where the flow comes back together — so once you have read one picture you have read all three.
For each — run the same steps once per row
This is the one that turns a query into a batch. Put the steps that make one document inside the loop, point the loop at a list, and they run once for every item in it.
Two ways to add one:
- On a step that returns a list — a SQL or Dataverse query, a CSV parse — open the card's menu and choose For each row in this result…. The loop lands under that step with the list already chosen.
- Or drag For each from the top of the palette and choose the list yourself in the inspector.
(for-each "each-row"
:label "One letter per person"
:in (output-of "rows" "rows") ; the list — a query's rows, $input.rows, or a literal list
:as "row" ; names the current item: $row inside the loop
(step "letter" …)
(step "render" …))
Inside the loop you get two new references, and they exist nowhere else:
$row— the whole current item. Named by:as; without it the name is$item.$row.email— one field of the current item.$index— which item this is, counting from zero.
Each pass through the loop sees the world as it was before the loop started, plus its own earlier steps — never another item's. Row 2's email cannot be handed row 1's PDF.
The loop's own settings sit on its card: how many items at a time (one by default, up to eight — it will not stampede your database), at most how many items (1,000 by default, 10,000 at the very most), and what happens if one item fails — carry on with the rest, which is the default, or stop at the first failure.
What comes out is stored under the loop's own id: one entry per item, in row order,
with an empty slot where an item failed, plus the list of failures. A later step reads
it the same way it reads any other step —
(output-of "each-row" "items"). The steps inside the loop are not
addressable afterwards: a step that ran 300 times has no single result to name.
If some items fail the run finishes: HTTP 200, with
loops in the response naming every failed row and why. If every
item fails the run did nothing it was asked to, so it answers HTTP 422
with FOR_EACH_ALL_FAILED — the same loops list, plus the
first row’s reason in the message. A whole batch usually fails for one reason, and
that reason is normally something you can fix in a minute.
A whole one, end to end — a query, then a document per row:
(pipeline "Example: A Document per Row"
(step "read-accounts"
:label "Query the accounts"
(action "DataverseQueryRows"
:connectionId "REPLACE_WITH_CONNECTION_ID"
:entitySet "accounts"
:select "name,emailaddress1"))
(for-each "each-account"
:label "One letter per account"
:in (output-of "read-accounts" "rows")
:max-parallel 4
:limit 500
(step "letter"
:label "Fill the letter template"
(action "ComposeFill"
:templateId "REPLACE_WITH_TEMPLATE_ID"
:data $item
:outputFormat "pdf"
:filename "Letter"))))
That loop names no :as, so the current item is $item. Four at a
time, five hundred at most.
Run in parallel — lanes that rejoin
A fan-out splits the spine into lanes that run at the same time. A lane can hold several steps, and they run in order within the lane:
(parallel "deliver"
(step "email" …) ; a one-step lane
(branch (step "stamp" …) ; a lane of two, run in order
(step "file" …)))
Lanes cannot see each other — they run concurrently, so each one reads the world as it was when the split happened. The step below the join is the merge: it runs once, after every lane has finished, and it can read each lane's result. A lane's result is its last step's. If one lane fails, its siblings and everything after the join still run, and the fan-out records which lane failed.
A whole one — a two-step lane, a one-step lane, and the merge below them:
(pipeline "Example: Two Lanes and a Merge"
(step "render"
:label "Render the document"
(action "ConvertHtmlToPdf"
:html "<h1>Quarterly Report</h1>"
:returnBase64 #t))
(parallel "deliver"
:label "Two lanes at once"
(branch
(step "stamp"
:label "Add a watermark"
(action "WatermarkPdf"
:pdf (output-of "render" "pdf")
:watermark.text "CONFIDENTIAL"
:returnBase64 #t))
(step "fingerprint"
:label "Hash the stamped copy"
(action "GenerateHash"
:data (output-of "stamp" "pdf"))))
(step "describe"
:label "Read its metadata"
(action "GetPdfMetadata"
:pdf (output-of "render" "pdf"))))
(step "count"
:label "The merge: runs after both lanes"
(action "ArrayCount"
:array (output-of "deliver" "succeeded"))))
The first lane is written as (branch …) because it holds two steps; the
second is a bare (step …) because it holds one. Both are lanes and the canvas
draws them the same way — the difference only shows in the Source tab.
When a step inside a lane fails, that lane stops there and everything else carries on. The steps that ran before the failure really ran: their results are kept and they are billed. A later step that refers to one that never ran is told exactly that — rather than "unknown step", which would send you hunting for a typo that is not there.
If — take one lane or the other
A decision compares one value and runs the matching lane. The other lane is skipped entirely, and skipped steps are never billed.
(if "route"
:when $input.status :is equals "approved"
(branch (step "approve" …)) ; runs when it matches
(branch (step "reject" …))) ; runs when it does not — optional
The comparison is one of equals, not-equals,
contains, greater, less and
exists, and it is evaluated once, before either lane starts. A field the
payload does not carry is treated as missing rather than as an error — routing on
whether something was supplied is the point of it. Numbers and the text of numbers
match each other, because a web form posts strings.
| Operator | True when |
|---|---|
equals | Both sides read the same. 5 and "5" match. Case-sensitive. |
not-equals | The opposite of the above. |
contains | The left side is a list holding that value, or text holding that text. |
greater / less | Compared as numbers when both sides are numbers, and alphabetically otherwise — which is how "2026-01-02" comes out greater than "2026-01-01". |
exists | The value is set and is not empty. It takes no comparison value; the box for one disappears when you pick it. |
Because only one lane runs, the price shown before the click is a ceiling — “up to 5 tokens” — not a total.
A whole one, with both lanes written out:
(pipeline "Example: Approved or Not"
(if "route"
:label "Is it approved?"
:when $input.status
:is equals "approved"
(branch
(step "letter"
:label "Render the approval letter"
(action "ConvertHtmlToPdf"
:html $input.letterHtml
:returnBase64 #t)))
(branch
(step "tell-them"
:label "Email the decision"
(action "SendEmail"
:to [$input.email]
:subject "We could not approve this"
:body "<p>Thanks for the submission.</p>")))))
The inspector writes :when and :is for you and reads the whole
thing back as a sentence on the card, so the picture and the source cannot drift apart.
One workflow runs another
The Run Workflow step runs another of your published workflows, waits for
it to finish, and hands what it produced to the steps below it. What you give it as
:payload becomes that workflow's $input; what that workflow
returns comes back as finalResult, so a later step reads
(output-of "hand-off" "finalResult").
(pipeline "Example: Route a New Order"
(step "hand-off"
:label "Run the order workflow"
(action "RunWorkflow"
:slug "REPLACE_WITH_WORKFLOW_SLUG"
:payload $input.row)))
Fed by a table watcher, this hands each new row to the order
workflow as its whole $input. The order workflow is then one workflow however
it is started: from here, from a web form, or from its own webhook.
- Only your own, and only published. The workflow named by
:slugmust be published and must belong to the same account. Any other slug gets the same not-found answer, whether it does not exist, is not published, or is someone else's. - Three deep at most. Counting the workflow the run began with: A may run B, and B may run C, but a Run Workflow step in C is refused. Whatever started A (a call, a form, a schedule or a watcher) does not count.
- Never a loop. A workflow cannot run itself, directly or through another one. The step is refused before anything in it runs.
- It waits. The step answers when the other workflow has finished; there is no start-it-and-move-on mode.
- How it is billed. The step costs 1 token, and the other workflow's steps bill on top at their own prices, all on your account. The other workflow's cost is set aside from your balance before it starts, so one you cannot afford is refused and none of it runs; if it stops part-way, only the steps that finished are billed.
The other things a step can refer to
| Write this | It means |
|---|---|
(output-of "step-id" "field") |
A field of an earlier step's result. Steps inside a lane may refer to steps above the split and to earlier steps in their own lane, never to a sibling lane's. |
$input |
The whole JSON body the webhook was called with — the form submission, as one object. |
$input.customerName |
One field of that body. The inspector lists the fields for you when a web form is attached to the workflow. |
$input.file.path |
The file a folder watcher delivered. See watch a folder for the fields each source sends. |
$input.row.email |
One column of the row a table watcher delivered. See watch a table. |
$item / $row |
The current item, inside a for-each and nowhere else. The
name is whatever the loop's :as says; the default is
item. See
Repeat, split and decide. |
$row.email |
One field of the current item. |
$index |
Which item this is, counting from zero. Inside a loop only. |
(output-of "each-row" "items") |
What a loop produced: one entry per item, in row order, with an empty
slot where an item failed. "failed" is the list of the ones
that did. |
(default (output-of "lookup" "email") "ops@example.com") |
Use the first value; fall back to the second when it is missing or empty. |
(concat "Receipt batch from " $input.supplier " on " $input.date) |
One piece of text joined from several values, in order, right where it is
used: a title, a subject, a file name. No extra step is needed. Text is
used exactly as written and numbers become text. If a part has no value
the run stops and says which part, so a title never reads "null"; wrap a
part in (default $input.reference "") to let it be blank. A
list or a whole record is refused: name one field of it. |
(file "contract.pdf") |
A file you uploaded in the builder's Files panel. For
trying things out — a published workflow gets its documents from
$input or from an earlier step. |
What the caller gets back, and what happens when a step fails
The terminator at the bottom of the spine names the step whose output is returned to the caller. By default that is the last step; set it to any step from the inspector when the thing you want back is not produced last — a workflow that renders a PDF and then files it away should return the PDF, not the filing receipt.
Each step also has an if this fails posture. The default stops the run and returns the error. You can instead let the pipeline carry on without that step, or nominate another step to run in its place — a card marked that way sits on the spine with a runs on failure of … chip so it is obvious what it is for.
What a published run answers
These are the answers a published webhook gives about the run itself. An answer to a run
that stopped part-way carries X-Tokens-Used, what stayed charged for the steps
that finished, and X-Correlation-Id, the reference to quote to support. A
missing or wrong key, a deactivated account, an unknown webhook, a body over the size
limit and a lookup that failed on our side are on the
Webhooks page, which lists every
answer a call made with your API key can get.
| Status | Code | When | What to do |
|---|---|---|---|
200 |
The run finished. A for-each in which only some items failed still
finishes, and loops names each failed row and why. |
Read result. |
|
400 |
FOR_EACH_LIMIT_EXCEEDED |
A for-each was handed more items than its limit. Nothing in the loop ran. | Send fewer, or raise the loop's at most. |
402 |
INSUFFICIENT_TOKENS |
The balance cannot cover the run, checked before it starts, or cannot cover a for-each or a Run Workflow step inside it, checked before that step runs. | Top up. The body says how many tokens were required. |
403 |
RUN_KEY_NOT_PERMITTED |
The call carried X-DBF-Run-Key, a header only the platform
may send (below). Refused before the workflow is loaded: nothing is set
aside and nothing runs. |
Drop the header. |
403 |
WATERMARK_NOT_PERMITTED |
The call carried X-DBF-Intake-Watermark, the starting point a
schedule sends when it delivers each row
once. Only the schedule runner may send it. Refused before the run is
reserved: nothing is set aside and nothing runs. |
Drop the header. |
409 |
IDEMPOTENT_LEDGER_UNAVAILABLE |
The guard that stops a step writing twice could not be reached, so that
step's write was held back, not sent. failedStepId names the
step. The steps before it that finished stay done and billed. A step
inside a for-each is reported as a failed row in loops
instead. |
Check what those steps did before you call again: a new call is a new run, and runs them again. |
422 |
FOR_EACH_ALL_FAILED |
Every item of a for-each failed. loops lists each row and
why. |
Fix that reason; a whole batch usually fails for one. |
500 |
PIPELINE_ERROR |
A step failed. The message is usually only a reference; a field path the data did not carry is named, with its step. The steps that finished stay billed and the rest is returned. | Run the workflow in the builder to find the failing step. If it does not fail there, quote the reference to support. |
500 |
BILLING_ERROR |
The tokens for the run could not be set aside, for a reason other than the balance. Nothing ran. | Call again: nothing ran the first time. |
400 |
RUN_KEY_WITHOUT_TRIGGER |
Answered only to the platform's own calls: X-DBF-Run-Key on
a run that no schedule or watcher started. |
Nothing. A call made with your API key meets the 403 above
first. |
400 |
BAD_RUN_KEY |
Answered only to the platform's own calls: an
X-DBF-Run-Key that is not a run identity. |
Nothing. A call made with your API key meets the 403 above
first. |
400 |
BAD_WATERMARK |
Answered only to the platform's own calls: an
X-DBF-Intake-Watermark that is not a starting point. Nothing
ran. |
Nothing. A call made with your API key meets the 403
WATERMARK_NOT_PERMITTED above first. |
400 |
WATERMARK_WITHOUT_INTAKE |
Answered only to the platform's own calls: a schedule set to deliver each row once started a workflow that has no query intake, so there is no query for the starting point to narrow. Nothing ran. | If one of your schedules fails with this, make the query step the
workflow's intake again. A call made with your API key meets the
403 WATERMARK_NOT_PERMITTED above first. |
A run's identity, and the header you may not send. Every run carries an
identity, and each destination write in it is keyed on that identity and the step. A call
made with your API key gets a new identity every time, so a second call is a second run
and writes again, including a retry your platform sends by itself: nothing in a second
request says it is the same delivery rather than a second, identical one. Only the
platform's own schedule runner and watchers name a run's identity, in the
X-DBF-Run-Key header, and of the two only a watcher
ever sends the same one twice: when it hands an item over again after a failed run, a
destination write that already landed is handed back instead of written a second time,
at no charge. A call made with your API key that sends X-DBF-Run-Key is
refused with 403 RUN_KEY_NOT_PERMITTED, because the identity
decides which writes a run skips, and a caller who could set it could make a run skip its
writes and still answer 200.
Four more 409 codes belong to the destination steps themselves, not to the
webhook: IDEMPOTENT_WRITE_IN_FLIGHT,
IDEMPOTENT_WRITE_UNRESOLVED, IDEMPOTENT_WRITE_OUTCOME_UNKNOWN and
IDEMPOTENT_BATCH_CHANGED. Each is a step refusing a write it was handed a
second time under the same identity, because sending it could land a record twice. A run
whose step refuses with one of them fails like any other step failure: when it stops the
run, a call made with your API key gets 500 PIPELINE_ERROR.
Try it without an account
The free playground is the same builder, running against a safe subset of the catalog, with no sign-up and nothing to install. Save, load and publish need an account; everything else works. Open Samples for a ready-made workflow — including one that fans out to two destinations and one that makes a document for every row in a list — or ask the assistant to build one from a sentence.