Workflows
An automation you configure once and then stop thinking about. Rows in, creatives out, delivered wherever you keep them — on a schedule, on an event, or from a URL.
What a workflow is#
Every part of this already exists as an API call: read a spreadsheet, render a template, push the URL somewhere. What integrators kept building on top of it was the same hundred lines of glue and a cron job. A workflow is that glue, stored as data and run by us.
The shape is always the same three moves:
| Stage | What it does | Examples |
|---|---|---|
| Source | Produces the rows. Replaces everything above it. | A Google Sheet, a JSON endpoint, a fixed list |
| Work | One output per row. | Render a template, capture a URL |
| Deliver | Pushes the results outward. | Your webhook, back to the sheet, Drive, email |
Between them sit the steps that narrow or reshape the rows — a filter, a limit, a field map. Steps run top to bottom and each one works on what the step above left behind.
Rows, and what happens to them#
A run carries an ordered list of rows. A source step replaces that list; everything else maps over it. When a step produces something, it writes the result back onto the row it came from — so a delivery step further down can name {{item.render.url}} and get the file that row produced.
{
"SKU": "KRT-114",
"Product name": "Cotton Kurta Set",
"Price": "1499",
"Stock": "24",
"_row": 7,
// added by the render step
"render": {
"id": "rnd_8f2a1c",
"status": "done",
"url": "https://cdn.pixbix.app/render/org_4c1d/rnd_8f2a1c/render.png",
"credits": 1
},
// added by the Drive step
"drive": { "id": "1a2B3c", "name": "KRT-114.png", "webViewUrl": "https://drive.google.com/…" }
}A row that fails does not stop the others. It is counted, logged with its row number, and dropped from what continues — so one malformed line in a 300-row spreadsheet costs you one creative, not the batch.
Placeholders#
Every string in a step’s configuration is a template. The syntax is the same one webhook payloads use, so there is one thing to learn.
| Placeholder | Resolves to |
|---|---|
{{item.COLUMN}} | Any field on the row. Column names come from your source. |
{{item.render.url}} | The file the render step produced for this row. |
{{item.screenshot.url}} | The capture, for a screenshot step. |
{{number}} | Row number within the run, starting at 1. |
{{trigger.payload.FIELD}} | Anything the hook body or the event carried. |
{{now.date}} | Today in the workflow’s timezone — 2026-08-15. |
{{workflow.name}} | The workflow, for file names and email subjects. |
{{run.id}} | This run, for tracing back from a delivered file. |
A value can carry formatters after a pipe, and you can chain as many as you like — each one takes what the last produced. {{item.Price | multiply: 1.18 | round: 2 | currency}} turns a spreadsheet’s 1,499 into ₹1,768.82 without a formula anywhere in the sheet. An unknown formatter passes the value through untouched rather than blanking it, so a typo costs you the formatting and not the headline.
Arguments come after a colon. Quote them when they contain a space, a comma or a pipe — {{item.Name | replace: 'Mr ', ''}} — and leave them bare when they are numbers: {{item.Price | round: 2}}.
Tidying up text
| Formatter | What it does |
|---|---|
default: 'there' | A fallback when the cell is empty. The difference between “Hi Priya” / “Hi there” and “Hi ”. |
trim | Strips surrounding spaces. squish collapses every run of them into one. |
title / capitalize / upper / lower | Case. title is Title Case; capitalize touches only the first letter. |
replace: 'old', 'new' | Swaps every occurrence. remove deletes one. |
prepend: 'Mr ' / append: ' Ltd' | Adds to the front or the back. |
truncate: 40 | Cuts to length on a word boundary, never mid-word. Takes its own suffix as a second argument. |
slice: 0, 8 / padStart: 4, '0' | A window of characters; padding to a fixed width. |
split: ',' / join: ', ' / first / last / length | Text into a list and back, and the ends of either. |
digits | +91 98765-43210 → 919876543210. What a phone number must be before it is sent anywhere. |
slug / urlencode / json | Safe for a file name, safe inside a URL, and the raw value as JSON. |
Doing sums
The arithmetic formatters are how a workflow calculates without a formula column in the source. number first if the cell carries a currency symbol or grouping — ₹1,499 is text until it is stripped.
| Formatter | What it does |
|---|---|
add / subtract / multiply / divide / mod | The four operations and the remainder. Dividing by zero leaves the value alone rather than producing Infinity. |
round: 2 / ceil / floor / abs | Rounding, to that many decimal places. |
decimals: 2 | 1499.5 → 1499.50. For a price that must always show its paise. |
min: 10 / max: 100 | Clamp to a floor or a ceiling. |
percentOf: 60 | 18 → 30. The share one number is of another. |
currency | 1499 → ₹1,499. Takes its own symbol and locale. |
{{item.Price | multiply: 2}} on a row where nobody filled the price in renders nothing at all — it does not render 0, because a poster advertising a product at zero rupees is a far worse failure than a blank.Dates, times, and values that change every row
date: 'DD MMM YYYY' formats any date — a column, or {{now.iso}}. The pattern is the spreadsheet one rather than strftime: YYYY, MMMM/MMM/MM, DD, dddd/ddd, HH/hh, mm, ss, A. A second argument names a timezone. Dates are read the way a sheet writes them, so 15/08/2026 and a Unix epoch both work, and addDays: 7, addHours, addMinutes and addMonths shift them.
| Placeholder | Resolves to |
|---|---|
{{now.datetime}} | 2026-08-15 09:42, in the workflow’s timezone. |
{{now.weekday}} / {{now.monthName}} | Saturday, August — for a caption that reads like a sentence. |
{{uuid}} | A fresh id, per row. The reliable way to stop filenames colliding. |
{{token}} | Eight random characters, for a shorter suffix. |
{{random}} | 0–1. Pair it with | multiply and | floor for a range. |
{{timestamp}} | Epoch seconds, read at that moment. |
{{now.*}} is fixed for the whole run, and deliberately so: a folder of Renders/{{now.date}} must not straddle midnight and file half a run under tomorrow. {{timestamp}}, {{uuid}} and {{random}} are the opposite — read fresh on every row."{{item.Price}}" sends a number, not a string — and so does "{{item.Price | multiply: 2}}", which matters when the template field expects one.Knowing what you can use#
Nothing here expects you to remember a field name. Every value in the builder is a dropdown of things that actually exist, and where they come from depends on the step.
| Source of the field | How it is known |
|---|---|
| Spreadsheet columns | Read live from the tab you picked, with a sample value beside each name. |
| Template fields | Loaded from the template — including undeclared {{placeholders}} in the design. |
| An API reply | From testing the step. Its real fields are then offered everywhere below it. |
| Render, capture and voice output | Declared by the step; shown underneath it as soon as it is added. |
Test a step to see what it returns
Sources and http.request carry a Test button. It runs that one step as currently configured — without saving — and lists the fields that came back with the values they held. Those fields then populate the dropdowns of every step below.
Testing is free and cannot write anything. It reads sources and calls read-shaped endpoints; it never renders, never spends credits, and never touches your spreadsheet. Steps that would cost money are not testable at all — they declare their output instead, which is why {{item.render.url}} is offered the moment a render step is added.
{ customer: { name } } offers {{item.response.customer.name}} rather than asking you to guess it. Arrays are described by their first element — one shape, not two hundred copies of it.Templates ask for everything they need
Choosing a template loads every value it takes: the fields it declares, and any {{PLACEHOLDER}} sitting in the design that was never turned into one. Both are filled the same way at render time, so both are asked for. Required fields are marked, and the header counts how many are still empty.
Changing the template afterwards clears values that belonged to the old one — they would otherwise keep being sent on every render, invisibly, with no row in the form to show them.
Capture presets
The capture step takes a saved screenshot preset. Choosing one lists what it will actually do — viewport, format, selectors to hide — because a preset is a name over twenty settings, and “Product pages” tells you nothing about the width it captures at. Anything set on the step refines the preset rather than being overridden by it.
Fetching only what you need#
A source does not have to hand over everything and let a filter throw most of it away. Conditions set on the source itself are checked as it reads, so rows that do not match are never fetched into the run, never queued as tasks and never rendered.
| Option | What it does |
|---|---|
| Only rows where this column is empty | The re-run switch. Point it at the column you write results into and the workflow picks up exactly the contacts that do not have one yet. |
| Only rows matching | Any conditions — status, plan, amount, a pattern. |
| Stop after N matches | Stops reading early, so “the first fifty that need one” costs one page rather than the whole sheet. |
Endpoints that page
source.http reads more than the first page. Three schemes are supported because APIs never agreed on one: page numbers, offsets, or a cursor the previous reply handed back. All three stop on an empty page and all three are bounded, so a misconfigured cursor cannot loop against somebody’s API.
Without this a source would quietly automate the first hundred products and ignore the other nine thousand — the kind of wrong that looks like it worked.
Triggers#
One trigger per workflow. It is the only thing that starts a run.
On a schedule
A five-field cron expression, read in an IANA timezone you choose. The timezone is not decoration: the schedule is evaluated in that zone through daylight saving, so 6am means 6am where you are, all year. A schedule computed in UTC would walk an hour twice a year, which for a “post at 9am” automation is the whole thing failing quietly.
| Expression | Means |
|---|---|
0 6 * * * | Every day at 06:00 |
0 9 * * 1-5 | Weekdays at 09:00 |
*/15 * * * * | Every fifteen minutes |
0 9 1 * * | The 1st of each month at 09:00 |
On an event
The same events webhooks deliver — render.completed, render.failed and the rest. The event payload is available throughout as {{trigger.payload.…}}.
A workflow never reacts to a render it produced itself, so “when a render finishes, render something” cannot loop. Each event also fans out to a bounded number of workflows, which caps the blast radius of a configuration that tries.
From a URL
A private hook URL any other system can POST to — a Shopify webhook, a Zapier action, a step in your deployment pipeline. The whole body arrives as {{trigger.payload.…}}.
curl -X POST https://pixbix.app/api/hooks/workflows/whk_9tK2… \
-H "Content-Type: application/json" \
-d '{ "campaign": "diwali-2026", "region": "west" }'
# 202
# { "success": true, "data": { "runId": "wfr_2b8e41", "status": "running" } }Every method works— GET, POST, PUT and PATCH. A write method’s JSON body becomes the payload; a GET’s query string does, so ?orderId=1234 arrives as {{trigger.payload.orderId}} just as a posted body would. That means a system that can only fire a GET, and a person testing the URL in a browser, both work.
Send one call before you build the steps. The builder records whatever arrives — whether or not the workflow is switched on — and reads the field names out of it, so every value dropdown below offers the real paths with the real values beside them instead of asking you to type {{trigger.payload.customer.email}} from memory. A call to a switched-off workflow answers 202 and says so; nothing runs.
Hardening, all optional.The URL alone is a bearer credential, and a URL travels where a header does not — browser history, a third party’s audit log, a screenshot in a ticket. Three controls layer on top of it, each off by default so a hook never starts demanding something nobody configured:
| Control | How the caller proves it | When to use it |
|---|---|---|
| Shared secret | X-Pixbix-Token | The upgrade almost every third party can actually make — nearly all of them send custom headers. |
| Signature | X-Pixbix-Signature | HMAC-SHA256 over the raw body. The proof differs per request, so a captured call cannot be replayed with a different body. |
| Replay window | X-Pixbix-Timestamp | Rejects a correctly signed call that is older than the window. Signing stops tampering; only this stops replay. |
| IP allowlist | — | Addresses or CIDR ranges. Most providers publish their egress ranges; pinning them turns a leaked URL into a nuisance. |
401 and the same body, whatever failed. A caller probing a leaked URL must not be able to tell a wrong secret from a wrong IP from a hook that does not exist — that difference is a map of what to try next.When new rows appear
A repeating check over a spreadsheet, for the shape everybody wants and nobody wants to build: “when a contact is added, make their video”. The workflow remembers the last row it collected, so each check reads only past it — a sheet of fifty thousand contacts costs one small read every fifteen minutes, not fifty thousand renders.
By hand
Always available, whatever the trigger. Run it once from the dashboard or with POST /v1/workflows/:id/run before you trust it to a schedule.
The steps#
| Step | What it does | Needs |
|---|---|---|
source.sheet | Rows from a Google Sheet, keyed by the header row. | Google connected |
source.drive | One row per file in a Drive folder. | Google connected |
source.contacts | One row per person in Google Contacts — name, email, phone, company, city. Narrow it to a label, and read-only. | Google connected |
source.whatsapp | One row per person in your MotherBot WhatsApp contacts, filterable by tag. Opted-out and blocked contacts are left out before anything is rendered for them. | MotherBot connected |
source.http | GET or POST an endpoint and take a list out of the response. | — |
source.list | A JSON array written into the step itself. | — |
filter | Drops the rows that do not match your rules. | — |
map | Adds or rewrites fields on every row. | — |
limit | Keeps the first N rows. | — |
delay | Waits, for a receiver that rate-limits. Up to 5 minutes. | — |
dedupe | Drops rows already handled, keyed on whatever identifies them. | — |
route | Sets a value by rule — a different template per segment. | — |
http.request | Calls your API per row and keeps the reply. | — |
drive.import | Fetches a Drive file and hosts it for the render. | Google connected |
screenshot.url | A capture per row, in real headless Chrome. | Screenshot access |
audio.voiceover | A personalised spoken script per row. | ElevenLabs connected |
captions.generate | Transcribes the voice-over into captions timed to the word, as cues and as .vtt/.srt files. | Google Speech-to-Text connected |
render.template | One image or video per row, from one of your own published templates. A library design is copied into your workspace first. | Render credits |
deliver.sheet | Fills a column on each row, or appends new ones. | Google connected |
deliver.webhook | POST the results to your own system, batched or per row. | — |
deliver.drive | Uploads each output into a Drive folder. | Google connected |
deliver.whatsapp | Sends each row’s own render to that row’s number on WhatsApp, in an approved template. | MotherBot connected |
deliver.email | One summary message listing what the run produced. | — |
A step can be marked carry on if this fails. That is right for a delivery that is nice to have and wrong for the render everything below it reads from — without the flag, a failing step ends the run and says why.
Writing back to the row it came from
deliver.sheet has two modes, and the default is the one personalisation needs. In update mode it fills named columns on the row each result came from — the contact already has a row, and what you want is their video URL beside their name. Nothing else is configured: the spreadsheet, tab and row number travel on the row from the source step.
{
"type": "deliver.sheet",
"config": {
"mode": "update",
"updates": [
{ "column": "Video URL", "value": "{{item.render.url}}" },
{ "column": "Rendered on", "value": "{{now.date}}" }
]
}
}A column is named by its header — "Video URL"— or by its letter. Headers are resolved against the live header row at write time, so a column inserted in the sheet last week cannot send this morning’s URLs into the wrong cells. A name that matches nothing is reported in the run log rather than written somewhere arbitrary.
append mode is the exception: it adds new rows at the bottom, for when the results are a fresh log rather than an update. It inserts rather than overwrites, so a totals row or a second table underneath survives.
Personalised voice-over
audio.voiceover speaks a templated script through your own ElevenLabs connection, once per row — so every contact hears their own name. The MP3 lands on the row as {{item.voice.url}}, ready to be the audio of a video template.
script and the engine times the captions against the voice — see the video guide. Characters come off your ElevenLabs plan; pixbix charges no credits for the audio.Two worked examples#
Both start in the dashboard from a recipe — New workflow creates every step below in order, already named, and leaves you picking a spreadsheet and a template. The JSON is what that produces, for anyone scripting it instead.
Personalised videos for a contact list
Render a video for every contact, write each URL back beside that contact, then tell your own system about it — one call per contact, carrying both the sheet data and the render.
[
{
"type": "source.sheet",
"name": "Read the contacts",
"config": { "spreadsheetId": "1BxiMVs0…", "tab": "Contacts" }
},
{
"type": "render.template",
"name": "Render their video",
"config": {
"templateId": "welcome-video",
"waitForCompletion": true,
"values": {
"name": "{{item.First name}}",
"company": "{{item.Company}}",
"city": "{{item.City}}"
}
}
},
{
"type": "deliver.sheet",
"name": "Write the URL back",
"config": {
"mode": "update",
"updates": [
{ "column": "Video URL", "value": "{{item.render.url}}" },
{ "column": "Rendered on", "value": "{{now.date}}" }
]
}
},
{
"type": "deliver.webhook",
"name": "Tell your system",
"config": {
"mode": "perItem",
"url": "https://api.acme.com/hooks/video-ready",
"payloadTemplate": "{\"email\":\"{{item.Email}}\",\"name\":\"{{item.First name}}\",\"videoUrl\":\"{{item.render.url}}\",\"row\":\"{{item._row}}\"}"
}
}
]waitForCompletion matters here. Video is queued, and the two steps below need the finished URL — leave it on whenever anything downstream reads {{item.render.url}}. Leaving the payload empty instead sends the whole row, sheet columns and render together, which is often all you need.Capture, voice-over, then render
The same list, with two more steps in front of the render: capture the page each contact’s row points at, and speak a script personalised to them. Both land on the row, and the render step consumes them as ordinary field values.
[
{
"type": "source.sheet",
"name": "Read the contacts",
"config": { "spreadsheetId": "1BxiMVs0…", "tab": "Contacts" }
},
{
"type": "screenshot.url",
"name": "Capture their page",
"config": { "url": "{{item.Dashboard URL}}", "options": { "viewport": { "width": 1280, "height": 720 } } }
},
{
"type": "audio.voiceover",
"name": "Speak their script",
"config": {
"voiceId": "21m00Tcm4TlvDq8ikWAM",
"text": "Hi {{item.First name}}, your {{item.Plan}} report for {{now.date}} is ready."
}
},
{
"type": "render.template",
"name": "Render their video",
"config": {
"templateId": "report-video",
"waitForCompletion": true,
"values": {
"screenshot": "{{item.screenshot.url}}",
"voiceover": "{{item.voice.url}}",
"caption": "{{item.voice.script}}",
"name": "{{item.First name}}"
}
}
},
{
"type": "deliver.sheet",
"config": { "mode": "update", "updates": [{ "column": "Video URL", "value": "{{item.render.url}}" }] }
},
{
"type": "deliver.webhook",
"config": { "mode": "perItem", "url": "https://api.acme.com/hooks/video-ready" }
}
]Order is the whole design here. The capture and the voice-over run before the render because the render reads what they produced; move either below it and the template receives an empty field rather than an error. The builder shows what each step leaves on the row underneath it, so the dropdown for a field only ever offers things that already exist at that point.
{{item.voice.script}} as a caption gives the engine the exact words that were spoken, so it can time the captions to the voice instead of guessing.Google Drive#
Drive works at both ends: as the place work arrives from, and as the place finished creatives go.
A folder as a source
source.drive gives every file in a folder its own row — filtered to images, videos or audio if you like, and paged, so a folder of ten thousand assets streams in windows exactly as a spreadsheet does. Paired with the when new files appeartrigger it lists only what changed since the last check, which is what makes “drop a product photo in this folder and get a video” cost one small query rather than a re-render of everything.
Making a file usable
A Drive link needs your credentials, so handing one to the render engine produces a 401 rather than a picture. drive.import downloads the file once and hosts it for the run, leaving {{item.file.url}} — drop that into any image or video field.
Google Docs, Sheets and Slides have no bytes to download and are refused with a message saying so. Export them from Google first.
Captioning the voice-over
captions.generate sends the voice-over to Google Speech-to-Text and gets every word back with its own timing. Put it under an audio.voiceover step and it needs no configuration at all — it defaults to {{item.voice.url}}, the clip that step just produced.
It leaves two things on the row, because they are wanted in different places. {{item.captions.cues}} carries per-word timings and is what a template embeds for burned-in captions that highlight each word as it is spoken. {{item.captions.vttUrl}} and {{item.captions.srtUrl}} are subtitle files — write one into a spreadsheet column, or hand it to a platform that takes a subtitle track.
Delivering on WhatsApp
deliver.whatsappsends one approved template per row from your MotherBot number, with that row’s render in the template’s media header. It is the step that makes a personalised-video run finish rather than stop at a spreadsheet column — the video reaches the person it was made for, on the channel they actually open.
Two WhatsApp rules shape the step, and both are Meta’s rather than ours. A business can only open a conversation with a template Meta has approved, so the template mode is the default and a plain message reaches only people who wrote to you in the last 24 hours. And a template can only carry a file of the kind its header was approved for — the picker greys out templates with no media header at all, and a video sent to an image template is refused before it costs a conversation.
The step asks two questions before anything else: which channel, and which connected number or address to send from. Both narrow what follows — templates are approved per WhatsApp number, so the template list is that number’s, and which message types are even possible is read off the chosen account rather than assumed from the channel’s name. The “send from” picker stays hidden until a workspace has more than one identity on a channel, because until then it has only one possible answer.
Attach takes any URL, from anywhere above the step: {{item.render.url}} for a video or image render, {{item.screenshot.url}} for a capture, {{item.file.url}} for a file imported from Drive, or a spreadsheet column that already holds a finished URL — in which case the workflow renders nothing at all and simply delivers. Leave it empty to send a template that has no media. There is no language setting: the template is looked up by name and sent in its own approved language.
Delivering back to Drive
deliver.drive uploads each finished output. The sub-folder is a template, created if it does not exist and reused if it does — Renders/{{now.date}} files a run by day without anyone maintaining folders, and naming a column instead files by region, campaign or customer.
What your plan allows#
Workflows are metered on four axes, not one, because they cost us in four different ways. A single limit on any one of them would price a five-minute schedule the same as a monthly report.
| Limit | What it caps | Why it exists separately |
|---|---|---|
workflowsEnabled | Whether this tier has workflows at all. | An automation is a standing commitment — it holds a scheduler slot and spends credits at 3am without anyone asking. |
workflows | How many you may save. | The count of things that exist, independent of how often any of them fires. |
workflowRunsPerMonth | Runs per calendar month, whatever the row count. | One workflow on a five-minute schedule costs 8,640 runs a month; five daily ones cost 150. Capping only the count would price those identically. |
workflowMaxSteps | Steps in a single workflow. | Depth is what decides a run’s cost: one workflow, run once, with forty steps over a thousand rows is forty thousand operations billed as a single run. |
The step limit is checked when you save, when you switch a workflow on, and when a run starts. That last one matters after a downgrade: a workflow saved under a longer allowance stays on disk and stays scheduled, so the run-time check is what stops it firing every morning on a plan that no longer includes it. It is refused rather than truncated — running the first eight steps of a twelve-step pipeline would render the videos and never deliver them, which is a worse failure than one that says why.
deliver.webhook step, which spends from the same monthly webhook-delivery allowance a registered endpoint does — it is the same outbound call either way.Running at scale#
A workflow is not a loop held in one process. Rows are written to a durable queue and worked from it, which is what lets a single run cover hundreds of thousands of videos without holding any of them in memory or losing the lot to a deploy.
| Phase | What happens | Cost |
|---|---|---|
| Collect | The source is read in windows and each row becomes a queued task. | Flat memory, whatever the row count |
| Process | Tasks are claimed in batches and worked in parallel. | Bounded by your render concurrency |
| Finalise | The once-per-run steps run over counts and a sample. | One pass |
Speed comes from concurrency, and concurrency is capped by your plan. A workflow asks for a number of rows to work at once; at run time that is clamped to concurrentRenders. That is deliberate — the render fleet is the real constraint, you have already bought a specific amount of it, and a workflow that ignored the cap would only queue work the fleet refuses. Raising throughput means raising the plan, not the setting.
What happens when something stops
Every row carries its own state and a step cursor. A row that was captured and voiced but not yet rendered resumes at the render — it does not pay ElevenLabs or the capture fleet a second time. A claim is a lease: if the process holding it goes away, the row returns to the queue and another picks it up. A run whose worker vanished mid-queue is resumed automatically.
None of that is best-effort recovery bolted on afterwards. It is the reason the queue is in the database rather than in a closure — a deploy in the middle of a six-hour run should cost seconds, not the run.
Retries
A row that fails is retried up to three times, unless retrying cannot help. Being out of credits, or pointed at a template that does not exist, fails identically every time — those stop immediately rather than burning three attempts and three log lines each. Running out of credits stops the whole run, because the alternative is one identical failure per remaining row.
What stops it running away#
An automation nobody watches needs different guarantees from an API call somebody is waiting on. These are structural rather than advisory:
- Rows per run. Whatever the source returned, collection stops at your ceiling — enforced before anything is queued, so a sheet that grew overnight cannot enqueue work nobody authorised.
- Skip if already running. On by default. A check that fires faster than the work finishes would otherwise stack runs until your whole render concurrency is one workflow arguing with itself. Skips are recorded, so you can see it happening.
- Credits stop the run, not the row. Running out mid-batch ends the run with one clear message instead of a hundred thousand identical failures.
- A monthly run allowance. Metered per run, separately from how many workflows you may own — one workflow checking every five minutes costs 8,640 runs a month, five daily ones cost 150.
- Renders are idempotent per row. A retried task, or two replicas meeting on a lease boundary, cannot render or charge for the same row twice.
source.http, an http.request or a deliver.webhook — are checked against the same guard the screenshot API uses. A workflow fetches a URL you supplied, from inside our network, on a timer, so addresses on private networks are refused.You are told when it breaks. A run started by a schedule, a check, an event or a hook that fails sends one email — to the workspace billing address, naming the step that gave up and why. One per failure, not one per run: the next only arrives after the workflow has worked again, so a frequent automation cannot bury the alert it sent first. Manual runs send nothing, because you are looking at the screen that already shows the error.
Reading a run#
Nobody is watching when a workflow runs, so the run record is the only account of it that will ever exist. It keeps the per-step outcome with item counts on each side, a capped log, the ids of every render it created, and the first few results.
{
"status": "partial",
"itemsIn": 30,
"itemsSucceeded": 28,
"itemsFailed": 2,
"creditsSpent": 28,
"steps": [
{ "type": "source.sheet", "status": "succeeded", "itemsIn": 1, "itemsOut": 30, "message": "30 rows from Products!A1:F500" },
{ "type": "filter", "status": "succeeded", "itemsIn": 30, "itemsOut": 30, "message": "30 of 30 rows matched" },
{ "type": "render.template", "status": "succeeded", "itemsIn": 30, "itemsOut": 28, "itemsFailed": 2,
"message": "Rendered 28 of 30, 2 failed" },
{ "type": "deliver.sheet", "status": "succeeded", "itemsIn": 28, "itemsOut": 28, "message": "Wrote 28 rows to Results!A31:B58" }
]
}partial is its own status and means what it says: every step ran to the end, and some rows did not make it. The step whose itemsOut is smaller than its itemsIn is where they went.
The working set itself is not stored. A run over 500 rows would otherwise keep 500 copies of your data forever, for no benefit that a sample of six does not give.
Endpoints#
Keys can read and run workflows. Creating and editing one is dashboard-only: a key that can rewrite where an unattended automation delivers is a key that can quietly redirect your creatives, and that is a decision, not an operation.
List workflows
/api/v1/workflowsAPI keyworkflows:readQuery parameters
pagenumberoptional- Page number, from 1.
limitnumberoptional- Rows per page, 1–100. Defaults to 20.
sortstringoptional- Column to order by: updatedAt, createdAt, name, lastRunAt, nextRunAt. Defaults to `updatedAt`.
order"asc" | "desc"optional- Sort direction. Defaults to `desc` for dates and counts.
searchstringoptional- Case-insensitive match across name, description. Up to 128 characters.
isActivebooleanoptional- Restrict to active or paused workflows.
activebooleanoptional- Older spelling of `isActive`, still accepted.
triggerstringoptional- manual, schedule, event, hook or poll. Comma-separate several.
{
"success": true,
"data": [
{
"id": "wfl_7c1f9a",
"name": "Daily product posters",
"isActive": true,
"trigger": {
"type": "schedule",
"cron": "0 6 * * *",
"timezone": "Asia/Kolkata"
},
"scheduleDescription": "At 06:00 (Asia/Kolkata)",
"nextRunAt": "2026-08-16T00:30:00.000Z",
"lastRunStatus": "succeeded",
"stats": {
"runs": 42,
"succeeded": 41,
"failed": 1,
"itemsProcessed": 1260
}
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 1,
"totalPages": 1
}
}Try it
/api/v1/workflows?page=1&limit=20&sort=updatedAt&order=desc&search=diwali&isActive=true&active=true&trigger=scheduleShow as cURL
curl -X GET "https://pixbix.app/api/v1/workflows?page=1&limit=20&sort=updatedAt&order=desc&search=diwali&isActive=true&active=true&trigger=schedule" \ -H "x-api-key: pk_live_your_key"
Get a workflow
/api/v1/workflows/:idAPI keyworkflows:readPath parameters
idstringrequired- Workflow id.
{
"success": true,
"data": {
"id": "wfl_7c1f9a",
"name": "Daily product posters",
"isActive": true,
"maxItemsPerRun": 100,
"skipIfRunning": true,
"trigger": {
"type": "schedule",
"cron": "0 6 * * *",
"timezone": "Asia/Kolkata"
},
"steps": [
{
"id": "stp_a1",
"type": "source.sheet",
"name": "Read a spreadsheet",
"isEnabled": true
},
{
"id": "stp_b2",
"type": "filter",
"name": "Keep only some rows",
"isEnabled": true
},
{
"id": "stp_c3",
"type": "render.template",
"name": "Render a template",
"isEnabled": true
},
{
"id": "stp_d4",
"type": "deliver.sheet",
"name": "Write back to a spreadsheet",
"isEnabled": true
}
]
}
}Try it
/api/v1/workflows/wfl_7c1f9aShow as cURL
curl -X GET "https://pixbix.app/api/v1/workflows/wfl_7c1f9a" \ -H "x-api-key: pk_live_your_key"
Run a workflow
/api/v1/workflows/:id/runAPI keyworkflows:runCost Free to start. The renders and captures inside the run cost their usual credits, and the run itself counts against your monthly workflow-run allowance.
Path parameters
idstringrequired- Workflow id.
Body parameters
payloadobjectoptional- Optional. Seeds the run and is readable in every step as {{trigger.payload.…}} — how a deployment pipeline passes a release name or a campaign id into the creatives.
{
"payload": {
"campaign": "diwali-2026",
"region": "west"
}
}Starting a run renders, spends credits and delivers to wherever the workflow points, so it is not fired from the docs.
List runs
/api/v1/workflows/:id/runsAPI keyworkflows:readPath parameters
idstringrequired- Workflow id.
Query parameters
pagenumberoptional- Page number, from 1.
limitnumberoptional- Rows per page, 1–100. Defaults to 20.
sortstringoptional- Column to order by: createdAt, startedAt, finishedAt, status. Defaults to `createdAt`.
order"asc" | "desc"optional- Sort direction. Defaults to `desc` for dates and counts.
statusstringoptional- queued, running, succeeded, partial, failed, cancelled or skipped. Comma-separate several to match any of them.
triggerstringoptional- What started the run: manual, schedule, event, hook or poll.
createdFromstringoptional- ISO date. Only runs started on or after it.
createdTostringoptional- ISO date. Only runs started on or before it.
{
"success": true,
"data": [
{
"id": "wfr_2b8e41",
"status": "partial",
"trigger": {
"type": "schedule",
"detail": "0 6 * * *"
},
"itemsIn": 30,
"itemsSucceeded": 28,
"itemsFailed": 2,
"creditsSpent": 28,
"durationMs": 84210,
"finishedAt": "2026-08-15T00:31:24.118Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 1,
"totalPages": 1
}
}Try it
/api/v1/workflows/wfl_7c1f9a/runs?page=1&limit=20&sort=createdAt&order=desc&status=failed&trigger=schedule&createdFrom=2026-08-01&createdTo=2026-08-31Show as cURL
curl -X GET "https://pixbix.app/api/v1/workflows/wfl_7c1f9a/runs?page=1&limit=20&sort=createdAt&order=desc&status=failed&trigger=schedule&createdFrom=2026-08-01&createdTo=2026-08-31" \ -H "x-api-key: pk_live_your_key"
Get a run
/api/v1/workflows/runs/:runIdAPI keyworkflows:readPath parameters
runIdstringrequired- Run id.
{
"success": true,
"data": {
"id": "wfr_2b8e41",
"workflowId": "wfl_7c1f9a",
"status": "partial",
"itemsIn": 30,
"itemsSucceeded": 28,
"itemsFailed": 2,
"creditsSpent": 28,
"steps": [
{
"stepId": "stp_a1",
"type": "source.sheet",
"status": "succeeded",
"itemsIn": 1,
"itemsOut": 30,
"message": "30 rows from Products!A1:F500"
},
{
"stepId": "stp_c3",
"type": "render.template",
"status": "succeeded",
"itemsIn": 30,
"itemsOut": 28,
"itemsFailed": 2,
"message": "Rendered 28 of 30, 2 failed"
},
{
"stepId": "stp_d4",
"type": "deliver.sheet",
"status": "succeeded",
"itemsIn": 28,
"itemsOut": 28,
"message": "Wrote 28 rows to Results!A31:B58"
}
],
"renderJobIds": [
"rnd_8f2a1c",
"rnd_8f2a1d"
],
"error": null,
"durationMs": 84210
}
}Try it
/api/v1/workflows/runs/wfr_2b8e41Show as cURL
curl -X GET "https://pixbix.app/api/v1/workflows/runs/wfr_2b8e41" \ -H "x-api-key: pk_live_your_key"
Every endpoint#
The rest of the public API, grouped by what it renders. Endpoints outside this page link to the reference that documents them.
Postman#
Generated from the same catalogue as this page, and it carries the dashboard-only authoring calls too. In Postman choose Import → Link and paste either URL.
Collection
Every public v1 endpoint, foldered by API, plus webhook management — with example bodies and saved responses.
https://pixbix.app/postman/collection.jsonOpen JSONEnvironment
baseUrl plus empty, secret-typed apiKey and authToken. Fill the credentials in Postman, not here.
https://pixbix.app/postman/environment.jsonOpen JSON