Skip to main content
A workflow is one JSON document: a statechart QuBe Sync runs against a QuickBooks company file. Build it on the dashboard’s canvas, write the JSON yourself, or have an AI assistant write it. Charts are designed so an agent writes them well and a person can review them.

Anatomy of a chart

Let’s break down a short chart, one part at a time. It finds the customer your app means by name, uses it when the name matches exactly, asks your app when the name only looks like one, and adds the customer when there’s nothing like it. The shipped ensure does the same for any kind of record, and also handles a name another list already has.
1

The run's inputs (from you, or another workflow)

Operations take exactly the same inputs as their corresponding REST endpoint. Here, it will need the body customer.create takes.
input (abridged)
2

How it can end

completed and failed always exist. These two tell your app which way it worked.
outcomes (abridged)
3

What each state sends

look_up asks the connection’s name index for the name. ask asks your app. create sends the caller’s own body, with $from.
4

Where each answer leads

Tried in order: the first whose if passes wins. Each description is drawn on the canvas. A match always raises success, so look_up branches on how sure its answer is.
states.look_up.on
5

One error handler, at the root

States can detect and branch on errors, but sometimes a catch-all is better. An error that’s unhandled in the state where it happens bubbles up. This transition on the workflow root catches any error unhandled by the states themselves.
on
6

What the run returns

Rendered when the run ends, from two places:
output
find_or_add_customer.json

Every key, for reference

input declares what a run is started with. A run whose input doesn’t match is refused before anything is sent.
Every state also takes entry and exit: assignments made when the run enters or leaves it. upsert counts its looks this way, and add_transaction its sends.
A wait state’s prompt is the question. Your app shows every question the same way, from the details and choices the run builds when it asks (Answering a question), so write them for the person who answers.{"$kind_name": "${input.kind}"} is a kind, or an operation’s (invoice.create), as a person reads it: Customer, Service item, Invoice. The validator checks every key, item outside a choose included. A template in details or a choose that doesn’t render when the run asks fails the run, as a bad message does.
What it raises. success or error, as QuickBooks answers.
With "paginate": true, a query goes through every result, page_size (1–200, default 100) at a time.Customers, vendors, items and every transaction query page this way. A query QuickBooks doesn’t iterate (accounts, classes and the other short lists, the deleted-records queries, the company queries, reports) answers whole and arrives as page 1 with more false, so the same chart works over it.A run takes at most 500 transitions, then fails with fault transition_limit. Each handled page costs about two (to the page’s state and back), so one run gets through about 250 pages: 25,000 records at the default page_size, 50,000 at 200. For more, raise page_size or split the job over runs, by date range say (the limits are under Good to know).
It only ever goes to the run’s webhook_url, as a workflow_run.notification, never to a URL of the chart’s own: url, webhook_url, operation, params, prompt, workflow, input, each and always are refused. A workflow with a notify state, or calling one with one, can’t start without a webhook_url (422 webhook_url_required).
Identifies a list record by name against the connection’s name index: the same ranked match as POST /connections/{connection_id}/matches. Every key is a template, like a request’s params, so ${input.kind} and ${vars.name} work.A key whose template renders to nothing is left out, and so is a blank parent or field, so one chart serves records with and without them. Literal values are checked by the endpoint’s own rules when the chart is saved: a kind from the list, a parent only for the kinds that have one, fields only for the kinds whose lists hold them, and a min_score from 0.3 to 1.0 that isn’t sent with exact_only. Keys of other state types (operation, params, prompt, data, always, paginate, settle and the rest) are refused.response.data is the answer the endpoint gives. match is exact, strong, candidates or none. record is set for exact and strong. candidates holds up to 10, with candidates_total and candidates_total_complete, and index has its state and as_of.A match step has no QuickBooks status codes to branch on. Branch on response.data.match.
It refreshes the index first. Every connection a run can start on (realtime or simulated: workflows run on realtime connections) has a name index. The run waits, parked and holding nothing, until the refresh is in, then matches, so the answer reflects QuickBooks as of that moment. A refresh that finished less than a minute ago is used as it is, so the matches of one burst (a transaction’s references, say) wait for one refresh, not one each. The first use builds the index. A run whose chart, or a workflow it calls, has a match state starts the build as the run starts, so it overlaps the steps before it, and the run waits while the index builds.Without an index, it falls back and is never refused. On a connection downgraded to standard while the run is going, or if the index’s build gives up, the step sends QuickBooks’ own filters instead, as ordinary requests of the run, settled like any other: the exact full name, and the “contains” name filter, for each list the kind covers. The answer has the same shape, with index.state unavailable: exact, or candidates without scores (score is null, the reason is contains the name), or none, and never strong. exact_only skips the “contains” query, and min_score and fields have nothing to apply to, so they are ignored. So the run finishes, without the index’s ranking, punctuation, suffixes, word order, typos and other fields.
Use only exact without asking. Acme Inc and Acme LLC are a strong match for each other, and they can be two companies. Send strong and candidates to a wait state that asks your app. When candidates_total is more than the 10 that came back, ask for a detail that tells them apart (a number, city, postal code or account number) and match again, rather than show a long list.
What the run records. The event log keeps a match step’s tier and counts, never the names, and when it waits for the index’s build or refresh (match_waiting, then match_answered). The run’s page says “building the name index” or “refreshing the name index” while it waits, then shows the tier and the candidates with their reasons. The run’s summary.matches lists each match step with its state, tier, counts and the index’s state, and is there only when the run matched.
The state’s on must handle every option id, and no other. An option can’t be called success, error, done or always.
Each event maps to a list of transitions, tried in order. The first whose if passes wins.
Handle errors once, at the root. An event no state handles bubbles up to the root. Put error → failed there once, and handle only the specific QuickBooks codes you care about (3100, say) where they happen. A lost answer and a stale edit_sequence (3200) don’t need handling: the run settles the one and, by default, asks your app about the other.
Names. success, error, done and always are reserved event names. State keys, option ids and variable names are lower case: a letter, then letters, digits and underscores, up to 50 characters (a workflow’s own key may run to 64).
Any string can reference the run’s context with ${...}:Eight $ keys build values, and no others are allowed:
completed and failed always exist. Declare more under outcomes: your app branches on the run’s outcome, so name every ending it should treat differently. No state may use an outcome’s key.A workflow that calls this one hears a success outcome as success and a failure as error, both with response.outcome naming it.
Every state, outcome and branching transition takes a description. It’s drawn on the canvas and shown in the usage contract, and it’s where the chart explains itself: “a customer has that name” rather than “success”.
The full rules are in the chart’s JSON Schema, served at /api/v2/workflows/schema.json, and in the authoring guide your assistant reads (Write one with an AI assistant).

Build it in the dashboard

On the Workflows page, New workflow starts a blank chart, clones a shipped template, or takes JSON you already have (Import a chart). Each opens the builder: Click a step to open it:
The inspector for ensure's wait state ask_use: the message with ${vars.name}, the Send with it JSON, the Details JSON (the kind's name and who holds it), and the Answers list with labels that send use_existing and rename, the second asking for a name field, with Picked from a list, or searched closed under it

A wait step: the message, what it sends with it, the details rows your app shows, and each answer with the option id it sends.

Every edit is checked as it’s saved. One that would make the chart invalid isn’t saved, and the builder says what’s wrong and where. Things that are allowed but probably wrong show as N suggestions in the corner of the canvas, which opens them in Workflow settings (More options → Suggestions).
A cloned template brings the workflows it calls; a pasted chart doesn’t. Clone on the Workflows page and Use in the JSON window both install the templates a template calls, as drafts. Pasting a chart (Import a chart, or the JSON window’s Definition) brings in that one chart only: install or create the workflows it calls first.

Write one with an AI assistant

Charts are built to be written by an assistant and reviewed by a person. Describe the job in prose: what should happen, and what should happen when QuickBooks says no. The assistant writes the JSON, and its reasoning ends up in the description fields, where the next person will read it.
1

Give it the rules

Connect your assistant to the MCP server:In the dashboard, the builder’s JSON window has a Prompt for an LLM tab with the same guide, plus the exact parameters and error codes of every operation the chart uses, and your app’s other workflows with their inputs and outputs.
2

Let it check its work

The assistant validates the chart until it’s clean, with the MCP validate_workflow_chart tool, or:It always answers 200, with valid and every error, each with the path to the problem. Once the chart is valid, warnings lists what’s allowed but probably wrong. Send the workflow’s key too, and it checks the workflows the chart calls:
3

Save it

PUT creates or replaces by key, so charts can live in your repository and be pushed from a deploy script. Add "publish": true to publish in the same call. The CLI does the same with qube workflows push ./chart.json, taking the key from the chart’s own key.
4

Hand the other side to your assistant too

GET /api/v2/workflows/{key}/usage.md describes how to call the workflow, generated from the chart itself. Give it to whatever writes your app’s side. A shipped template’s is GET /api/v2/workflow-templates/{key}/usage.md, readable before you install it.It covers how to start the workflow, every question it can ask (with example webhooks and answers), every outcome and what’s in its output. It describes the published version (the working copy, until there is one); add ?version=working, or a version number, for another.In the builder it’s the Use from your app tab:
The builder's Use from your app tab for ensure: Published v1, Copy everything as Markdown, a Start it curl command, and the input table

Use from your app: the usage contract for ensure, ready to copy as Markdown.

Test it

Run it against a simulated connection from the builder’s Test run panel or the API. The panel docks beside the chart, so the chart shows the run’s path as it goes. On a sandbox app it can run the working copy, the published version or an older one.
The Test run panel beside ensure's chart: Waiting for an answer at ask_rename; 2 earlier steps, then try_add Handled, holder_kind, find_holder, read_holder and same_list Done, and ask_rename Waiting; and the head of the question, Your answer is needed

Test run, beside the chart: the run's status, its steps (an error the chart handles is Handled), and its question.

Walk the unhappy paths too:
A 3200 fault doesn’t make a conflict prompt. A fault’s answer carries no record, so the run reads the record back, finds nobody changed it, and sends the update again. And with your own edit_sequence there’s no baseline, so as_read is null and every field that differs counts as a conflict.

Versions and publishing

A workflow has a working copy, which is what you edit, and numbered published versions, which are immutable snapshots.
A run finishes on the version it started with, even if you publish mid-run or delete the workflow. Deleting keeps its runs, and their webhooks carry workflow_id: null.

From sandbox to production

Build and publish in your sandbox app. Then promote the published version to your production app: In Versions, Promote to another app… copies the published version into another app under the same key, and records where each version came from. It brings along the workflows it calls, showing each one as created, updated or skipped in the target, with what will change. Publish there as well is ticked by default. Untick it to land the chart as that app’s working copy for a final look.
Values that belong to one company file have to be confirmed. Before promoting, QuBe Sync warns about a list_id or txn_id written into the chart, or a hard-coded reference. They work against your test company and fail in your customers’ files: pass them in as inputs, or look them up by name in a query state. To promote, you confirm the highlighted values mean the same thing in the target’s company file.
Promotion deliberately has no API. It’s the step where a person, not a script or an assistant, moves a chart to production.
Make promotion the only way in. An organization owner can turn on Workflows in production apps can only change by promotion, under How production apps change on the dashboard’s Workflows page:
Deploying from CI instead, with that setting off? Read the version with your sandbox key (GET /api/v2/workflows/{key}/versions/{n}) and PUT it with your production key.

Composing workflows

A workflow state can run another workflow (under the same app) as one step. Prefer combining workflows to just copying another chart’s states. For example, add_transaction runs two other workflows, each once per reference its add makes:
add_transaction.json (two of its states, abridged)
Once per item. With each, the state runs the workflow once per element of a list: one at a time, in order, at most 200. The state raises one success with the outputs in response.data, how each ended in response.outcomes, and failures in response.errors. When a stop_on ending ended the loop, response.data and response.outcomes stop at that element, and response.stopped says which and how: {"index": 0, "outcome": "cancelled"}. Branch on it to your own ending, as add_transaction does when your app cancels at one of its references: {"if": {"path": "response.stopped", "op": "present"}, "target": "cancelled"}.
A workflow state takes no always, params, operation or prompt.
Your app sees one run. A question the called workflow asks arrives on it, and so does everything the called workflow does (Good to know). Publishing waits for the workflows it calls to be published first (Versions and publishing).

One chart for any record type

find_or_add_customer names its operations, so it only ever finds and adds customers. A chart can take its operations as input instead: declare an operation input without an operation, and it takes a pair, an operation and its params. A request state sends the pair with a templated operation and $from, as stream_query does:
A run of it then passes the operation it wants, here a vendor query:
ensure builds its operation that way: it takes a kind and the add to send, and sends "${input.kind}.create", so {"kind": "vendor", "add": {"name": "Lone Star Supply Co"}} adds a vendor. Start from the shipped generic charts rather than writing the states again: A chart for many records of any kind is then a single workflow state. push_records is one: it runs upsert once per record your app sends, each record the kind, add and update that upsert takes, records of different lists mixed:
None of these handles lost answers: the run settles those before a state hears of them. upsert does handle one race. When its add meets 3100 because something created the name after the lookup, it runs resolve once more, counting its looks with $add in the state’s entry. When the second look finds nothing too, another list holds the name, and it runs ensure with lookup: false for ensure’s question.

Sending your app data from a chart

A notify state hands your app data while the run goes, not only at the end (what your app receives). The usual use is a whole list, a page at a time. This is stream_query’s loop:
In data, response is the response that brought the run to the state (on a paginating query’s page, that page), so ${response.data} is the page’s records. Handle it in the chart, by going to a wait that asks whether to retry, say. Unhandled, the run fails.

Matching names in a chart

Your app knows a customer as ACME, Inc.. QuickBooks finds Acme Inc only by that exact spelling. A match state asks the connection’s name index instead, which ranks the file’s names against yours, and the chart branches on how sure the answer is. This is look_up, the first state of a chart that finds a customer by name:
look_up
The transitions are tried in order, so the exact and none tiers go first, then the crowded answer, and what is left goes to a wait state that asks your app. email is left out when the run was started without one, and city and postal_code until your app has sent them: a key that renders to nothing is dropped, so one chart serves records with and without them.
find_customer.json
Matching says what each tier means and how current the index is. The state’s keys and events are in Match states.

Settling lost answers

When QuickBooks never answers a request a run sent, the run settles it before the state raises anything: it finds out whether QuickBooks applied it (Lost answers).
Every add a workflow sends carries an external_guid, when the operation has one: yours if you set it, otherwise one the run generates. That’s nearly every transaction, plus customers, vendors, employees, other names and items. QuickBooks stores it on the record, which is how the run tells its own invoice from another one for the same customer on the same day.

What the state raises

What the answer would have raised, usually success, with two more fields: Two endings are left for the chart to handle, both as error with code null:
unsettled
no_response
An unsettled message is always QuickBooks never answered <operation>, and <why>. A no_response message starts QuickBooks never answered: and says why: the session ended, the request was withdrawn before it was sent, the Web Connector reported a failure in place of the answer, or the request ended in some other state. reason says the same as a word to branch on, and an add’s external_guid is the GUID it was sent with:
Some requests aren’t settled, and raise no_response straight away: one your app withdrew, one QuickBooks couldn’t parse (xml_error), a paginated query, an operation with nothing to look for afterwards (such as a void or a merge), and any request state with "settle": false.

Doing the looking in the chart

"settle": false hands a lost answer to the chart at once, as no_response, with response.error.reason saying why and, for an add, response.error.external_guid, the GUID it was sent with. That makes the looking part of the chart, where the diagram and the run’s log show it. No shipped template does it any more: add_transaction leaves its add to settling, and asks your app at ask_about_add about an unsettled add or a no_response that reached QuickBooks, with why in the question’s data saying which, and candidates_complete whether the candidates are all there could be. A chart that looks for itself:
QuickBooks returns the GUID exactly as it was sent, braces and upper case. An external_guid you send yourself should be written that way too.
An add such a chart found counts as the change it was, once, at the state that sent it. Its entry in summary.requests reads as one the run settled itself: "status": "success", "settled": "found", the record’s identifiers, and found_by, the state whose answer carried it. It’s in summary.side_effects, and the run’s page lists it under Changed in QuickBooks:
summary of a chart that looked for its own lost invoice add (abridged)
A change the run sends again and gets an answer to is counted once, as that answer. One nothing in the run settles keeps "status": "error", gets "unconfirmed": true, and is listed under summary.unconfirmed instead: your app picked another invoice, say, or the run failed or was cancelled first. It may or may not be in QuickBooks, so look before sending it again.

Conflicts in a chart

When an update meets a record someone changed after it was read, the run compares fields before anything is overwritten (Conflicts). It compares each field the update sends with the record as it is now (QuickBooks answers the 3200 with it; when an answer doesn’t, the run reads it again) and, when the run read the record itself, with the record as it was then.
Lines, linked transactions and data extensions aren’t compared. The run’s own read doesn’t include them, so they’re left out of both sides.
Leave edit_sequence out. An update in a workflow doesn’t need one: the run reads the record first and fills it in. That read is the as_read the diff is judged against, and it’s what lets the run tell their changes from yours. Pass your own edit_sequence and there’s no baseline, so every field that differs from QuickBooks counts as a conflict.
If the read finds nothing, the state raises error 3120 (object_not_found), such as “nothing to update: no item_non_inventory has this id; the record was deleted, or it is another type of record”. The read goes by the update’s own type, so an inventory item’s list_id sent to item_non_inventory.update finds nothing too: update a record with the operation for its type.