Skip to content

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:

StageWhat it doesExamples
SourceProduces the rows. Replaces everything above it.A Google Sheet, a JSON endpoint, a fixed list
WorkOne output per row.Render a template, capture a URL
DeliverPushes 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.

Every step type is one a machine can finish on its own. There is no approval step and no “wait for a human”, deliberately: a workflow that can block on a person is a workflow that stops silently at 3am and is discovered on Friday.

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.

A row, after three steps
{
  "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.

PlaceholderResolves 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

FormatterWhat it does
default: 'there'A fallback when the cell is empty. The difference between “Hi Priya” / “Hi there” and “Hi ”.
trimStrips surrounding spaces. squish collapses every run of them into one.
title / capitalize / upper / lowerCase. 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: 40Cuts 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 / lengthText 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 / jsonSafe 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.

FormatterWhat it does
add / subtract / multiply / divide / modThe four operations and the remainder. Dividing by zero leaves the value alone rather than producing Infinity.
round: 2 / ceil / floor / absRounding, to that many decimal places.
decimals: 21499.5 → 1499.50. For a price that must always show its paise.
min: 10 / max: 100Clamp to a floor or a ceiling.
percentOf: 6018 → 30. The share one number is of another.
currency1499 → ₹1,499. Takes its own symbol and locale.
An empty cell stays empty. {{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.

PlaceholderResolves 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.
A placeholder that is the whole value keeps its type. On a numeric column, "{{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 fieldHow it is known
Spreadsheet columnsRead live from the tab you picked, with a sample value beside each name.
Template fieldsLoaded from the template — including undeclared {{placeholders}} in the design.
An API replyFrom testing the step. Its real fields are then offered everywhere below it.
Render, capture and voice outputDeclared 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.

Nested replies are flattened into real paths, so an endpoint returning { 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.

OptionWhat it does
Only rows where this column is emptyThe 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 matchingAny conditions — status, plan, amount, a pattern.
Stop after N matchesStops reading early, so “the first fifty that need one” costs one page rather than the whole sheet.
This is the difference between a workflow you can safely run twice and one you cannot. A sheet of fifty thousand contacts whose condition matches forty of them costs forty tasks and forty renders — not fifty thousand of anything.

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.

ExpressionMeans
0 6 * * *Every day at 06:00
0 9 * * 1-5Weekdays at 09:00
*/15 * * * *Every fifteen minutes
0 9 1 * *The 1st of each month at 09:00
Minute resolution, by design — a sub-minute schedule is a polling loop, not an automation. Preview the next five fire times before you switch it on; the builder shows them live, from the same parser the scheduler runs.

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.…}}.

Trigger
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:

ControlHow the caller proves itWhen to use it
Shared secretX-Pixbix-TokenThe upgrade almost every third party can actually make — nearly all of them send custom headers.
SignatureX-Pixbix-SignatureHMAC-SHA256 over the raw body. The proof differs per request, so a captured call cannot be replayed with a different body.
Replay windowX-Pixbix-TimestampRejects a correctly signed call that is older than the window. Signing stops tampering; only this stops replay.
IP allowlistAddresses or CIDR ranges. Most providers publish their egress ranges; pinning them turns a leaked URL into a nuisance.
Every rejection returns the same 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.
Without those, the URL is the secret. Treat it like an API key, and rotate it from the dashboard if it leaks. Rotating retires the old URL immediately; there is no grace period, because the reason to rotate is that somebody else has it.

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.

The cursor advances only after rows are safely queued, never before. A cursor moved first and a process that died second would skip those contacts permanently, with nothing recording that they were missed.

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#

StepWhat it doesNeeds
source.sheetRows from a Google Sheet, keyed by the header row.Google connected
source.driveOne row per file in a Drive folder.Google connected
source.contactsOne row per person in Google Contacts — name, email, phone, company, city. Narrow it to a label, and read-only.Google connected
source.whatsappOne 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.httpGET or POST an endpoint and take a list out of the response.
source.listA JSON array written into the step itself.
filterDrops the rows that do not match your rules.
mapAdds or rewrites fields on every row.
limitKeeps the first N rows.
delayWaits, for a receiver that rate-limits. Up to 5 minutes.
dedupeDrops rows already handled, keyed on whatever identifies them.
routeSets a value by rule — a different template per segment.
http.requestCalls your API per row and keeps the reply.
drive.importFetches a Drive file and hosts it for the render.Google connected
screenshot.urlA capture per row, in real headless Chrome.Screenshot access
audio.voiceoverA personalised spoken script per row.ElevenLabs connected
captions.generateTranscribes the voice-over into captions timed to the word, as cues and as .vtt/.srt files.Google Speech-to-Text connected
render.templateOne image or video per row, from one of your own published templates. A library design is copied into your workspace first.Render credits
deliver.sheetFills a column on each row, or appends new ones.Google connected
deliver.webhookPOST the results to your own system, batched or per row.
deliver.driveUploads each output into a Drive folder.Google connected
deliver.whatsappSends each row’s own render to that row’s number on WhatsApp, in an approved template.MotherBot connected
deliver.emailOne 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.

Update the source row
{
  "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.

Pass the same words to a caption 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.

Steps
[
  {
    "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.

Steps
[
  {
    "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.

Passing {{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.

A row carries the file’s details, not its bytes. That split is deliberate: listing a folder stays cheap and resumable, and fetching is charged per row where it belongs.

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.

A finished video cannot be transcribed directly: Speech-to-Text reads audio encodings, not video containers. Caption the voice-over, then render the video with the captions on it — which is the order a workflow runs in anyway. Transcription is billed by audio-minute to your own Google Cloud project, and pixbix charges no credits for it.

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.

Sends are billed as conversations on your MotherBot plan; pixbix charges no render credits for delivery. Numbers are cleaned up before sending — a leading zero, spaces and a “+” all survive the trip — but a number with no country code is failed for that row rather than guessed at.

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.

Folders are looked up before they are created, so running the same workflow tomorrow reuses today’s folder. Drive happily allows two folders with the same name, which would otherwise split a customer’s output across duplicates.

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.

LimitWhat it capsWhy it exists separately
workflowsEnabledWhether 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.
workflowsHow many you may save.The count of things that exist, independent of how often any of them fires.
workflowRunsPerMonthRuns 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.
workflowMaxStepsSteps 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.

Rows inside a run are not metered here. They pay their own way in render credits, and charging both would bill the same work twice under two names. The one exception is adeliver.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.

PhaseWhat happensCost
CollectThe source is read in windows and each row becomes a queued task.Flat memory, whatever the row count
ProcessTasks are claimed in batches and worked in parallel.Bounded by your render concurrency
FinaliseThe 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.

Rough arithmetic for planning: at 30 concurrent renders and roughly 30 seconds a video, a run produces about 3,600 videos an hour. A hundred thousand is therefore a long weekend of unattended work — which is exactly the kind of job that has to survive a restart, and does.

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.
Outbound URLs — in a 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.

GET /v1/workflows/runs/:runId
{
  "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

GET/api/v1/workflowsAPI keyworkflows:read
Every workflow in the workspace, most recently changed first, with its trigger, its next scheduled run and how the last one went.

Query parameters

page
numberoptional
Page number, from 1.
limit
numberoptional
Rows per page, 1–100. Defaults to 20.
sort
stringoptional
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.
search
stringoptional
Case-insensitive match across name, description. Up to 128 characters.
isActive
booleanoptional
Restrict to active or paused workflows.
active
booleanoptional
Older spelling of `isActive`, still accepted.
trigger
stringoptional
manual, schedule, event, hook or poll. Comma-separate several.
Response
{
  "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=schedule
Show 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

GET/api/v1/workflows/:idAPI keyworkflows:read
One workflow, including its full step list — useful for checking what a run is about to do before you fire it. The inbound hook token is never returned here; it is readable only in the dashboard.

Path parameters

id
stringrequired
Workflow id.
Response
{
  "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_7c1f9a
Show as cURL
curl -X GET "https://pixbix.app/api/v1/workflows/wfl_7c1f9a" \
  -H "x-api-key: pk_live_your_key"

Run a workflow

POST/api/v1/workflows/:id/runAPI keyworkflows:run
Starts a run and returns immediately with its id — a run over hundreds of rows takes minutes, so holding the request open for it would time out at every proxy in between. Poll the run, or let the workflow's own delivery steps tell you. If a run is already in flight and the workflow skips overlaps, the response says `skipped` and nothing is started.

Cost 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

id
stringrequired
Workflow id.

Body parameters

payload
objectoptional
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

GET/api/v1/workflows/:id/runsAPI keyworkflows:read
The run history for one workflow, newest first. The step breakdown and log are omitted here — fetch a single run for those.

Path parameters

id
stringrequired
Workflow id.

Query parameters

page
numberoptional
Page number, from 1.
limit
numberoptional
Rows per page, 1–100. Defaults to 20.
sort
stringoptional
Column to order by: createdAt, startedAt, finishedAt, status. Defaults to `createdAt`.
order
"asc" | "desc"optional
Sort direction. Defaults to `desc` for dates and counts.
status
stringoptional
queued, running, succeeded, partial, failed, cancelled or skipped. Comma-separate several to match any of them.
trigger
stringoptional
What started the run: manual, schedule, event, hook or poll.
createdFrom
stringoptional
ISO date. Only runs started on or after it.
createdTo
stringoptional
ISO date. Only runs started on or before it.
Response
{
  "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-31
Show 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

GET/api/v1/workflows/runs/:runIdAPI keyworkflows:read
One run in full: the per-step breakdown with item counts on each side, the capped log, the ids of every render it created, and the first few results. This is the endpoint to poll after starting a run, and the one to read when an overnight automation did something unexpected.

Path parameters

runId
stringrequired
Run id.
Response
{
  "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_2b8e41
Show 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 JSON

Environment

baseUrl plus empty, secret-typed apiKey and authToken. Fill the credentials in Postman, not here.

https://pixbix.app/postman/environment.jsonOpen JSON