Skip to main content
Real jobs rarely fit in one QuickBooks request. Invoicing an order from your store means making sure the customer exists, handling the case where a vendor already has their name, making sure every item exists, and only then adding the invoice. If the connection drops during any part of that process, you have to recover gracefully and reconcile what mutations were made or not. Naive solutions lead to duplicate transaction records and headaches. Workflows let you encapsulate all those decisions in one piece of logic, with all the tools to let your LLM of choice safely help you. Workflows are also composable, so you can build complex systems from small, re-usable pieces (including the templates we provide).

Handles the edge cases

Names already taken, records someone else changed, races with other apps editing QuickBooks.

Asks when it can't decide

Escalate decisions to your app or user when only a human can decide.

Survives lost answers

When QuickBooks’ answer never arrives, the run finds out what was applied.
Part of the upsert chart: resolve, then update when it finds the record, cancelled when your app stops at one of its questions, or create when nothing is the record, with a retry from create back to resolve when the name was taken since the lookup

Upsert anything in QuickBooks: find it, then update it, or create it when nothing is it. If another person or app takes the name between the lookup and the create, it looks once more.

Workflows run on realtime connections

A run waits on QuickBooks at every step, and often on a person answering a question, so workflows run on realtime connections, which check in every 10 seconds, and on simulated ones while you build. Create the connections your app runs workflows on with "type": "realtime" (see Onboarding). Your sandbox app isn’t billed, so realtime costs nothing there. Starting a run on a standard connection is refused, in a sandbox app too, with nothing stored:
422
A connection’s type can’t be changed through the API: upgrade it from its page in the dashboard. Nothing was stored, the Idempotency-Key included, so once it’s realtime the same call starts the run. A realtime connection slowed to every 5 minutes, or with a downgrade scheduled, still runs workflows. When a downgrade takes effect at the end of the billing period, runs already going finish, at the standard pace, and new ones are refused. In the dashboard, a workflow’s Run drawer offers Upgrade to realtime in place of Start when the connection you pick is standard, and a connection’s downgrade warns you when it has started a run in the last 30 days.

Run your first workflow

This runs ensure for a customer whose name a vendor already has (in a simulated connection), so you see a question and its answer.
1

Install the template

That installs and publishes it, and resolve, which it calls, in one go.
A template cloned in the dashboard is a draft: publish it with Publish v1 in the builder. A sandbox connection can run a draft; a production connection can’t.If the app already has a workflow at that key, the install is refused with 409 (key_taken). Add "replace": true to replace the workflow and all its dependencies. To install a second copy instead, just use another key with {"as": "new_key"}.
Installing copies the template’s chart into your app, so when we fix or extend a template, your workflow keeps the chart it has until you install it again. GET /api/v2/workflows says when there’s something to take: each workflow’s template names the template it came from, and template.status is update_available once that template has changed. Install it again with "replace": true, plus "publish": true to make it live (and "as" with your workflow’s key, if you installed it under another one). From the CLI, that’s qube workflows install KEY --replace --publish.update_available_customized means you’ve edited the workflow since you installed it, so replacing it discards your edits: compare it with GET /api/v2/workflow-templates/{key} first. Each workflow a template installs alongside it reports its own template.In the dashboard, the Workflows page marks such a workflow Template updated, and its page offers Update to the current template.
2

Start a run

The input names the list the record goes to (kind) and its add, the same body POST /customers takes, so a workflow can be a drop-in for the request it replaces:This returns 201 with the run object as JSON. reference is your own label for the run: the record it’s about plus a UUID, new for every run (up to 255 characters). The run and every webhook it sends carry it, so save it on your record before you start the run, and a webhook that arrives before this response still finds it. Send it as the run’s Idempotency-Key too: a retry of the start then gets the same run back, and the next run about this contact, with a reference of its own, is a run of its own. webhook_url is optional, but a run started without one sends no webhooks, and a workflow that sends your app data can’t start without one (422 webhook_url_required).
3

Answer its question

The run looks for the customer first and finds none. But Lone Star Supply Co is a vendor in the simulator, and QuickBooks keeps one name space for customers, vendors, employees and other names, so the add meets error 3100. The run finds what holds the name, stops at ask_rename and sends workflow_run.awaiting_input. Its prompt says the name already belongs to a vendor, lists what the question is about in details, and offers two choices:When the record holding the name is in the same list, ensure asks at ask_use instead, and offers use_existing too.Show the question to your user the way you’d show any question: the message, the details rows, and a button per choice, with an input for each of its fields (Answering a question). Then POST the picked choice’s id as choice, with its fields in data, to links.decisions. That link ends ?prompt_token=…, naming this question, so sending the answer again after a timeout can’t answer a later one:A run can wait as long as it needs to. Your user can answer tomorrow.
workflow_run.awaiting_input
details and choices are what to show. data.existing is the record that has the conflicting name, read whole from QuickBooks by its ListID, as its own list’s query answers it, and its list says which list it’s on (vendor, customer, employee, …). options are the same answers as the chart declares them, for code that answers with option. links.search is null: nothing here is searched.
You can use the app to test workflow runs: open the links.ui field to answer the question in the app, or just find the running workflow on the Workflow Runs page.
A run's question, under Your answer is needed: Lone Star Supply Co already belongs to a vendor, its details (Customer: Lone Star Supply Co, Held by: Vendor), and two choices, each saying where it leads (Add it under another name, picked, with Lone Star Supply Co (customer) typed as the new name, which continues at try_add or ends renames_exhausted; Cancel, which ends cancelled), and a Send answer button

The same question on the run's page (links.ui), drawn from its details and choices as your app draws it. Answering here is the same as POSTing to links.decisions.

4

Get the result

The run adds “Lone Star Supply Co (customer)” and sends workflow_run.completed. Its outcome is created_renamed: store output.record.list_id against your record, and you’re done.
workflow_run.completed (abridged)
headline says what happened in one sentence, for a log line or a status on your page: show it, never parse it. output has only the keys this ending sets. The webhook’s summary is what your app acts on: what the run changed in QuickBooks (changes: the action, the record’s kind and its ids, and item for one element of a list) and every decision it was given (question, the prompt.id it answered, choice, at, and item for one element of a list), plus unconfirmed when a change QuickBooks never answered couldn’t be settled, notifications when the run sent your app data, and stopped_at when it failed or was cancelled. The run itself (GET links.run) has the whole summary, which also names the state that made each change (side_effects) and lists every request the run sent (requests), the workflows it called (calls), what its matches found (matches) and what a cancel withdrew (discarded). Those lists grow with the run, so a webhook leaves them out.

Pick a template

Start from a shipped template rather than a blank chart. Each one is complete and tested, and a template that calls other workflows installs those too. The generic ones work for any record type. resolve, ensure and upsert take a kind (customer, item_service, account, …) and a name, which they match by rank against the connection’s name index. add_transaction takes any transaction add, and resolve_transaction and stream_query take the query to send, such as {"operation": "customer.query", "params": {"active_status": "All"}}. Find a record, without writing. Each asks your app to pick one when it isn’t sure. Write. Each can stop to ask your app before it writes. Send QuickBooks data to your app. Each sends notifications, so each needs a webhook_url. Read the company’s setup.
A question’s prompt.id is the path of the state that asks it, such as ask_use. When one workflow calls another, the calling state comes first. resolve asks ask, ask_detail, ask_name or ask_pick, which upsert and ensure relay as resolve / ask and the rest; resolve_transaction asks ask, and void_transaction and record_payment relay find / ask. upsert relays ensure’s as taken / ask_rename (or ask_use). push_records relays upsert / resolve / ask (or upsert / taken / ask_rename) for whichever record asks, and add_transaction relays find_references / ask (or ask_detail, ask_pick, …) in its first pass and create_missing / ask_use (or ask_rename) in its second, for whichever reference asks: prompt.from.item_index says which, counting from 0, and item_count how many there are. Its own ask_missing asks about every missing record at once, between the two passes. An add_transaction installed before it had ask_missing never asks it: install the template again with --replace ("replace": true) to get it.Every template can also end failed, when something goes wrong that it doesn’t handle. error says why: an object with QuickBooks’ code when QuickBooks refused something, a type, a plain message, QuickBooks’ own words in quickbooks_message, and a hint.
An invoice whose customer the run created under a new name, abridged:
add_transaction output (added)
Each key is the path your app sent the reference at, so it reads the record for the customer it sent at customer_ref, and for its first line’s item at invoice_line_add.0.item_ref. The lines come back in the order sent: record.invoice_line_ret[0] is that first line, with its txn_line_id.
Read a template’s whole usage contract before you install it with GET /api/v2/workflow-templates/{key}/usage.md, or /usage for JSON: how to start it, every question it can ask (its prompt.id, the options to answer with, and the webhook it arrives in) and every way it can end. It’s the contract GET /api/v2/workflows/{key}/usage gives once the template is installed with the templates it calls, so the questions and outcomes your app handles are known before anything is installed. The CLI prints it with qube workflows templates add_transaction --usage. In the dashboard, open Workflows and clone any template into your sandbox app.
The Start from a template section of the Workflows page: four template cards with a description and Use template, the first, Add a transaction, opened to its app select (My App Dev), Clone and Cancel, and each card noting the templates it also installs, such as ensure and resolve

Workflows → Start from a template. A card names the templates it also installs.

What your app receives

A run reports to the webhook_url it was started with, and sends nothing if it was started without one. The QuickBooks requests it sends are ordinary queued requests, visible in the dashboard, but they don’t send webhooks of their own: the run’s events are the whole story. They’re signed like every other QuBe Sync webhook (verify them), and your endpoint answers the same way: any 2xx within 5 seconds.

You start it

POST /workflow-runs answers 201, with the run running.

While it runs

awaiting_input when it needs a decision, notification when it sends your app data.

Once, at the end

completed, failed or cancelled.
Deliveries can fail, repeat, or arrive early and out of order:
If one endpoint takes both request and run webhooks, route on event and handle the run’s in a job of its own:

Answering a question

Every question arrives the same way, whatever it asks, so your app needs one screen for all of them, not a branch per question. Show prompt.message, then prompt.details (label and value rows: what the question is about), then one button per entry of prompt.choices, in order: its label, with its description and detail under it, and an input for each of its fields. POST the picked one’s id as choice, with its fields in data, to links.decisions.

One choice per record the run found

When the run offers records to pick from, such as the near matches resolve found, each is a choice of its own: use:1, use:2, …, at most 25. Its value is QuBe Sync’s, so the choice has no fields and you send {"choice": "use:2"}. Your app never handles the ListID or TxnID, and never sends one it picked itself or one a person typed.

A search for another

On a realtime or simulated connection, a question that picks a record also offers a search: a choice such as {"id": "use", "label": "Use this one"} with a field of type record. Fill it from GET links.search?field=list_id&q=lone and send the value of the one picked: {"choice": "use", "data": {"list_id": "80000012-1712345678"}}.
It answers at most 20 records of the field’s kind, best first, from the connection’s name index. detail is for a person to read: the record’s list, then its company name, email, phone, city and postal code or account number where QuickBooks has them:
links.search carries the prompt’s token, so a search for a question the run has moved on from is a 409 prompt_changed. q is 2 to 100 characters (422 invalid_q), field must be a record field of one of the choices, or a rows field’s record column (422 invalid_field), and while the index is still being built the search is a 409 name_index_building, as a match is. A connection downgraded to standard while a run is going has no name index any more: a question asked after that offers no search choice and its links.search is null, and a search for one asked before is 422 matching_requires_realtime. Workflows run only on realtime or simulated connections, so a question’s search is there whenever its kind is one the index holds (every list but inventory sites, which resolve lists whole).

A table of rows

A field of type rows asks the same columns of each row of a list, in one question: add_transaction’s ask_missing has a row for each record QuickBooks doesn’t have. Show it as a table. Its columns are shared by every row (so your screen can set one column on many rows at once), and each row says which of them it asks. Show a row’s column only when it’s in the row’s columns and its when holds over the row’s values and what your user has picked so far, and show or hide it as they change. Search a record column with GET links.search?field=records.list_id&row=customer_ref&q=lone: row picks that row’s list (422 invalid_row without it when the list depends on the row). Answer every row, with only the columns it asks:
A required column that applies and is left out is 422 missing_fields, named records.<key>.<column> (a row left out is records.<key>); a column the row doesn’t ask, or a value outside its enum, is 422 invalid_data. An answer to a column that doesn’t apply is dropped. A choice the prompt doesn’t offer is 422 invalid_choice, a field the choice doesn’t list (or a value of the wrong type) is 422 invalid_data, and a missing required one is 422 missing_fields, each naming what to fix while the run keeps waiting. options, the answers as the chart declares them, still work: {"option": "rename", "data": {...}} answers too, which is how a chart’s own tests and an agent that reads the chart answer.

Two kinds of question

workflow_run.awaiting_input carries the question in prompt, and prompt.kind says who is asking. Both kinds are answered at links.decisions.
In the decision, data.existing is the record that holds the name, which find_holder found and read_holder read whole by its ListID, and its list says which list it’s on. In the conflict, the run was changing Riverbend Electric’s phone and email. Since it read the record, someone in QuickBooks changed the phone too, and the last name. The email isn’t in conflicts, because nobody else touched it. The last name is in theirs, because the update doesn’t send it.
Different fields to send instead are an object, which no choice asks for, so they go with {"option": "overwrite", "data": {"params": {...}}}. With them, the fields in params replace the ones the update sent, and the run adds the record’s id and current edit sequence. params must be an object of fields the update takes, like data.sent. One it doesn’t take is refused with 422 (invalid_data, naming the field), and the run keeps waiting. If the record changes yet again first, the run asks again, and after three writes the 3200 stands as the state’s error. With keep, the run carries on with the record as QuickBooks has it. To refuse both, cancel the run.A conflict answer isn’t a step of the chart, so it isn’t in summary.decisions. It’s in the run’s event log, as a decision_received event whose payload has "kind": "conflict".

Sending your app data

A run can hand your app data while it goes, not only at the end: a notify state (“Send to the app” in the builder) sends a workflow_run.notification to the run’s webhook_url, under a name your app routes on. The usual use is a whole list, a page at a time: pull_changes sends page with seq 1, 2, 3…, each naming its kind, the customers, then the vendors, then whatever else you asked for (with a since, then one deleted), then workflow_run.completed with the counts. Save each record by the connection the run is on, its kind and its list_id or txn_id, never by the id alone: QuickBooks makes ids unique only within one list family of one company file (customers, vendors, employees and other names share one), so another customer’s file (or a copy of this one) can hold the same ids. The run waits for your 2xx before it goes on: notifications come one at a time, in seq order, and all of them before the run’s ending, so workflow_run.completed means everything was sent. QuickBooks isn’t held up: the pages keep being fetched while your app takes them. The run’s ending counts them: summary.notifications has how many were sent and how many failed.
One run sends at most about 250 pages: 50,000 records at 200 a page. A run caps at 500 transitions, and a page costs two (to the notify state and back).
A notification has the fields every event has, plus:
To write a notify state of your own, see Build your own workflow. Pagination says when to use a workflow for an import and when the plain paginated API.

How a run ends

A chart’s own cancelled ending, when your app answered cancel, is a completed run: nothing is wrong.
A chart can declare named failures, so your app can tell them apart by outcome. ensure ends renames_exhausted with the error Five renames were tried and every name was taken; the last was "<name>".Someone cancels a run with POST links.cancel or Cancel run. An optional {"reason": "..."} is kept as cancel_reason, trimmed and cut to 500 characters. The workflows the run calls stop with it. Requests not yet handed to the Web Connector are withdrawn; one already sent may still complete. The run says which (GET links.run): withdrawn ones are in summary.discarded, and one still out is "status": "in_flight" in summary.requests. Cancelling never undoes what was applied: summary.changes, in the workflow_run.cancelled webhook too, lists it, so you can check or reverse it, and summary.unconfirmed lists any change QuickBooks never answered that the run hadn’t settled yet. Cancelling a run that has ended is 409 already_finished.

What the run handles for you

Two things can go wrong between a run and QuickBooks that a single request would leave to your app. A workflow handles both.

Lost answers

Now and then QuickBooks gets a request and its answer never comes back: its computer lost its connection, or QuickBooks or the Web Connector was closed, while the request was being processed. On its own, such a request ends timed_out, and QuickBooks may or may not have applied it (Error handling). A workflow doesn’t leave that to you; the run settles the request: it constructs a fingerprint from the original request details and queries QuickBooks to find out if it was applied.
Your app has nothing to do. A change the run found this way is in summary.changes like any other, with settled, and the run’s page lists it as “confirmed after a lost answer”. A transaction found this way is then read whole by its TxnID, so add_transaction’s output.record has its lines either way.
If the run can’t tell whether the mutation was applied, the chart decides what happens next; a chart that doesn’t handle it fails the run with a message saying why. A mutation that couldn’t be confirmed is listed under summary.unconfirmed and on the run’s page as Not confirmed: it may or may not be in QuickBooks, so look before sending it again. To watch it happen, use a simulated connection to arm a lost answer fault before a run sends a creation request. On the connection’s page, set Next request to “the next add, change or delete is applied, then the Web Connector dies before answering”, run qube simulator faults <connection> --next lost, or set the simulator’s next_lost_response fault through the API. The fault waits for the next request that changes the company file, so the lookups a run sends first are answered normally, and the first add, change or delete is applied and its answer lost. How the run settles each kind of request, and how a chart can do the looking itself, is in Settling lost answers.

Conflicts

QuickBooks refuses an update based on stale data: error 3200, a stale edit_sequence. Retrying with the fresh edit sequence would quietly overwrite whatever the other person changed. A workflow looks first and compares each field in the update request with the more recent version in QuickBooks. If you want to build a chart that overwrites or fails instead of asking, see Conflicts in a chart.

Typed starts in your code

Your app’s published workflows have types: GET /api/v2/workflows/types returns each one’s input as JSON Schema, with the body of a QuickBooks operation as a reference to that operation’s params. Each library has a generator that turns it into a start method per workflow, and one per operation where an input picks the operation, with the field docs and types the library’s operation methods already have:
In Python it’s add_transaction.invoice(...) and in Node addTransaction.invoice(...), and the input types have the same names in both (AddTransactionInvoiceInput).
invoice_input(...) builds the same input without starting a run, which is what an operation class’s input returns. The file is checked in. Run the generator again when you publish a change, and with --check in CI: it exits non-zero once the file’s digest differs from the server’s (and only warns when it can’t reach QuBe Sync). --drafts types each workflow’s working copy where it has one, to write code against a version before you publish it. A body’s required fields are the operation’s, less the ones the workflow sets itself (record_payment fills the payment’s customer_ref, total_amount and applied_to_txn_add from the invoice), which the generated types leave optional and their docs name. The types are a hint, never the authority: QuBe Sync checks every start, so a stale file means a missed type, not a wrong run. A start with a field the operation doesn’t take is a 422 naming it.

Follow your runs

Every run has a page in the dashboard, at its links.ui. Workflow runs in the dashboard’s menu lists them all.
The Workflow runs page: state tabs with one run needing an answer, filters for workflow, connection, start and outcome, the waiting run pinned with its question and an Answer button, and today's runs with their status, workflow, connection, start time and how long each took

Workflow runs: the ones waiting for an answer first, then every run with where it is or how it ended.

Click into a run to see its details
A completed add_transaction run's page: Completed, ended added; Changed in QuickBooks, listing the customer Bluebonnet Bakery and the item Site survey that its second pass created, and the invoice INV-1042, with their ListIDs and TxnID; and the Steps tab with list_references, find_references (resolve, 3 of 3 done), still_missing, create_missing (ensure, 2 of 2 done) and add (invoice.create), each Done with how long it took

A finished add_transaction run: what it changed in QuickBooks, and its steps.

From your app, GET /connections/{id}/workflow-runs?state=awaiting_input lists the runs waiting on your users, and ?reference= the runs you started with a reference.

The API at a glance

All under https://qubesync.com/api/v2/connections/{connection_id}, with your app’s API key. The API reference has every field. A workflow your run calls has no run id of its own as far as your app is concerned: everything it does is part of the run you started.

Good to know

Use an Idempotency-Key to make sure you’re not starting duplicate runs. See Retrying a call safely. The run’s new reference makes a good key: the record’s id plus a UUID, made once when your app decides to start the run, saved on the record, and sent again only to retry that same start. QuBe Sync’s libraries send the reference as the key for you. Never build either from the record alone (order-1042): the next run for the order would get the first run back (or a 422, if its input differs) instead of starting, and once the first run is purged (60 days after it ends) a retried start would start a new one. A reference of its own for every run also keeps the first run’s webhooks from being taken for the second’s, whose revisions start again at 1.
A workflow state runs another workflow as one step, optionally once per item in a list (each). When building complex flows, prefer combining simpler workflows rather than shoving everything into one chart.
A match state identifies a list record by name: ACME, Inc. finds Acme Inc, whatever the punctuation, company suffix or word order. It is the ranked match the matches endpoint does, after the run catches the connection’s name index up with QuickBooks (unless that was done in the last minute, which the matches of one burst share). A run still going when its connection is downgraded to standard sends QuickBooks’ own “contains” filter instead, so it can finish. The chart branches on response.data.match, and uses only an exact match without asking: see Matching names in a chart.
A change we make to a template reaches your workflow only when you install it again with "replace": true. template.status on GET /workflows tells you when there’s one to take.
On a production connection, a run always uses the workflow’s published version. Sandbox and simulated connections run your latest edits by default, so you can test before publishing. Nested workflows do the same, running their working copies for a working-copy run, their published versions for a published one. See Build your own.

Next

Build your own workflow

Chart anatomy, the builder, writing charts with an AI assistant, lost answers and conflicts in a chart, versions, and promoting to production.

API reference: Workflows

Every workflow, template and run endpoint.