> ## Documentation Index
> Fetch the complete documentation index at: https://docs.qubesync.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Workflows

> Let QuBe Sync run multi-step QuickBooks jobs for you. It asks your app when a decision is needed, and tells you how the job ended.

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).

> [card] [Handles the edge cases](#pick-a-template)
>
> Names already taken, records someone else changed, races with other apps editing QuickBooks.

> [card] [Asks when it can't decide](#two-kinds-of-question)
>
> Escalate decisions to your app or user when only a human can decide.

> [card] [Survives lost answers](#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](https://mintcdn.com/qubesync/0vBf1cM4bTpV_SLq/images/workflows/builder.png?fit=max\&auto=format\&n=0vBf1cM4bTpV_SLq\&q=85\&s=c83d8c8b1d29a71f92b9c345aa6513b6)

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](https://docs.qubesync.com/onboarding.md#add-the-connect-to-quickbooks-button)). 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:

```json 422 theme={null}
{
  "errors": {
    "code": "realtime_required",
    "detail": "Workflows run on realtime connections, and this connection is standard. Upgrade it to realtime from its page in the dashboard (Upgrade to realtime, under Speed); in a sandbox app that costs nothing, since sandbox apps aren't billed. A simulated connection runs workflows too."
  }
}
```

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](https://docs.qubesync.com/simulator.md)), so
you see a question and its answer.

**1. Install the template**

Request

<pre>`POST 
          /api/v2`</pre>

```http theme={null}
POST /api/v2/workflow-templates/ensure/install

{"publish": true}
```

```bash theme={null}
curl -u "$QUBE_API_KEY:" \
  -X POST \
  "https://qubesync.com/api/v2/workflow-templates/ensure/install" \
  -H "Content-Type: application/json" \
  -d '{"publish": true}'
```

That installs and publishes it, and `resolve`, which it calls, in one go.

> [accordion] Already installed, or cloned in the dashboard?
>
> 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"}`.

> [accordion] When a template changes
>
> 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:

Request

<pre>`POST 
          /api/v2`</pre>

```http theme={null}
POST /api/v2/connections/{connection_id}/workflow-runs

{
  "workflow": "ensure",
  "input": {
    "kind": "customer",
    "add": {
      "name": "Lone Star Supply Co",
      "email": "orders@lonestarsupply.example"
    }
  },
  "reference": "contact-381-9b2f6c1e-0d4a-4f7e-8a7b-2a1c3d4e5f60",
  "webhook_url": "https://your-app.example/webhooks/qube"
}
```

```bash theme={null}
curl -u "$QUBE_API_KEY:" \
  -X POST \
  "https://qubesync.com/api/v2/connections/$CONN_ID/workflow-runs" \
  -H "Content-Type: application/json" \
  -d '{
    "workflow": "ensure",
    "input": {
      "kind": "customer",
      "add": {
        "name": "Lone Star Supply Co",
        "email": "orders@lonestarsupply.example"
      }
    },
    "reference": "contact-381-9b2f6c1e-0d4a-4f7e-8a7b-2a1c3d4e5f60",
    "webhook_url": "https://your-app.example/webhooks/qube"
  }'
```

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](#sending-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`:

| `choice` | `label` | Fields |
| - | - | - |
| `rename` | Add it under another name | `name`, a string labelled New name |
| `cancel` | Cancel | |

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](#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:

Request

<pre>`POST 
          /api/v2`</pre>

```http theme={null}
POST /api/v2/connections/{connection_id}/workflow-runs/{run_id}/decisions

{
  "choice": "rename",
  "data": {"name": "Lone Star Supply Co (customer)"}
}
```

```bash theme={null}
curl -u "$QUBE_API_KEY:" \
  -X POST \
  "https://qubesync.com/api/v2/connections/$CONN_ID/workflow-runs/$RUN_ID/decisions" \
  -H "Content-Type: application/json" \
  -d '{
    "choice": "rename",
    "data": {"name": "Lone Star Supply Co (customer)"}
  }'
```

A run can wait as long as it needs to. Your user can answer tomorrow.

> [accordion] The whole awaiting\_input webhook
>
> ```json workflow_run.awaiting_input {13-42} theme={null}
> {
>   "event": "workflow_run.awaiting_input",
>   "timestamp": 1790470628,
>   "app_id": "98f339a4-32e8-424a-987e-ccaf5bcaa620",
>   "sandbox": true,
>   "connection_id": "4fdde583-68b1-4c6b-94d1-82abf97afdf5",
>   "workflow": "ensure",
>   "workflow_id": "01a0e05a-3c52-7a8e-b1f4-6d2e9c0a7b31",
>   "run_id": "fb1951a1-25b9-4fb2-98c7-c988988ecdf7",
>   "reference": "contact-381-9b2f6c1e-0d4a-4f7e-8a7b-2a1c3d4e5f60",
>   "revision": 6,
>   "run_state": "awaiting_input",
>   "prompt": {
>     "id": "ask_rename",
>     "token": "Vq3kT9xRb2LmW8sZ",
>     "kind": "decision",
>     "message": "\"Lone Star Supply Co\" already belongs to a record of another list (vendor), and QuickBooks keeps names unique across customers, vendors, employees and other names, and across every type of item. Add this one under another name, or cancel.",
>     "data": {
>       "kind": "customer",
>       "name": "Lone Star Supply Co",
>       "existing": { "list": "vendor", "list_id": "80000022-1790470623", "edit_sequence": "1790470623",
>                     "name": "Lone Star Supply Co", "company_name": "Lone Star Supply Co",
>                     "email": "orders@lonestar.example", "is_active": true, "balance": 0.0,
>                     "time_created": "2026-09-22T09:30:23", "time_modified": "2026-09-22T09:30:23" }
>     },
>     "details": [
>       { "label": "Customer", "value": "Lone Star Supply Co" },
>       { "label": "Held by", "value": "Vendor" }
>     ],
>     "options": [
>       { "id": "rename", "label": "Add it under another name",
>         "fields": { "name": { "type": "string", "required": true, "label": "New name" } } },
>       { "id": "cancel", "label": "Cancel",
>         "description": "Stop here. Nothing is created." }
>     ],
>     "choices": [
>       { "id": "rename", "label": "Add it under another name", "description": null, "detail": null,
>         "fields": [ { "name": "name", "type": "string", "label": "New name", "required": true } ] },
>       { "id": "cancel", "label": "Cancel", "description": "Stop here. Nothing is created.",
>         "detail": null, "fields": [] }
>     ]
>   },
>   "links": {
>     "run": "https://qubesync.com/api/v2/connections/4fdde583-…/workflow-runs/fb1951a1-…",
>     "decisions": "https://qubesync.com/api/v2/connections/4fdde583-…/workflow-runs/fb1951a1-…/decisions?prompt_token=Vq3kT9xRb2LmW8sZ",
>     "search": null,
>     "cancel": "https://qubesync.com/api/v2/connections/4fdde583-…/workflow-runs/fb1951a1-…/cancel",
>     "ui": "https://qubesync.com/app/workflow_runs/fb1951a1-…"
>   }
> }
> ```
>
> `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](https://mintcdn.com/qubesync/0vBf1cM4bTpV_SLq/images/workflows/run-waiting.png?fit=max\&auto=format\&n=0vBf1cM4bTpV_SLq\&q=85\&s=db4228b0bcba7caeac15e6c03fd85723)

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.

> [accordion] The whole completed webhook
>
> ```json workflow_run.completed (abridged) theme={null}
> {
>   "event": "workflow_run.completed",
>   "run_id": "fb1951a1-25b9-4fb2-98c7-c988988ecdf7",
>   "reference": "contact-381-9b2f6c1e-0d4a-4f7e-8a7b-2a1c3d4e5f60",
>   "revision": 11,
>   "run_state": "completed",
>   "headline": "Created Lone Star Supply Co (customer) in QuickBooks under the new name.",
>   "workflow": "ensure",
>   "outcome": "created_renamed",
>   "output": {
>     "record": { "list_id": "80000024-1790470634", "full_name": "Lone Star Supply Co (customer)",
>                 "email": "orders@lonestarsupply.example", "is_active": true, … },
>     "list": "customer"
>   },
>   "summary": {
>     "ended_at": "2026-09-27T00:57:14.482113Z",
>     "changes": [
>       { "action": "create", "kind": "customer", "list_id": "80000024-1790470634",
>         "full_name": "Lone Star Supply Co (customer)" }
>     ],
>     "decisions": [
>       { "question": "ask_rename", "choice": "rename", "at": "2026-09-27T00:57:10.418204Z" }
>     ]
>   }
> }
> ```
>
> `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](#sending-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](https://docs.qubesync.com/matching.md) 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.

| Template | What it does |
| - | - |
| `resolve` | Finds a list record of any kind (a customer, a job, a vendor, an item, an account) by its stored ListID, then by its name, matched by rank. |
| `resolve_transaction` | Finds a transaction by its TxnID, then its reference number, then your filters and the GUID your app kept. |

**Write.** Each can stop to ask your app before it writes.

| Template | What it does |
| - | - |
| `ensure` | Finds a list record of any kind as `resolve` does, and creates it from your add when nothing is it. A record of the same list with exactly the name is used without asking; a near match, or the name held by another list, is your app's to decide. |
| `upsert` | Updates a list record if it finds one, as `resolve` does, and creates it if not. A name another list holds is your app's to decide, as in `ensure`. |
| `push_records` | Runs `upsert` for up to 200 records, in order, in one run. |
| `add_transaction` | Makes sure every customer, item, class and account a transaction names exists, then adds it, and says for each how it was found or made. Any transaction. |
| `record_payment` | Finds an invoice and records a payment against it. A payment over what the invoice owes, or for an invoice QuickBooks doesn't have, is your app's to decide. |
| `void_transaction` | Finds a transaction, then voids or deletes it. |

**Send QuickBooks data to your app.** Each sends [notifications](#sending-your-app-data), so each needs a
`webhook_url`.

| Template | What it does |
| - | - |
| `stream_query` | Pages through any query you pass and sends each page. |
| `pull_changes` | Every record of the kinds you name (an initial import), or what changed since a date and then what was deleted. |

**Read the company's setup.**

| Template | What it does |
| - | - |
| `company_profile` | The company, its preferences and its host, in one Web Connector session. Run it when a connection is set up. |

> [accordion] What each template takes, asks and ends with
>
> | Template | What it does | Asks your app | Ends |
> | - | - | - | - |
> | `resolve` | Finds a list record of `kind` (`customer`, `vendor`, `employee`, `other_name`, `entity` for those four, `item` or one item type, `account`, `class`, `terms`, `sales_tax_code` and the other lists a transaction names) by the `list_id` your app stored, then by `name`, under `parent` (full name or ListID) for a job or a sub-item. The name is [matched](https://docs.qubesync.com/matching.md) by rank on a realtime or simulated connection, with `fields` such as `email` or `account_number` for a customer, vendor, employee or other name, and through QuickBooks' own exact and "contains" filters on a connection downgraded to standard while the run is going. Only an exact match is used without asking, or a strong one with `accept: "strong"`. What it finds is read whole by its ListID before it's used, and matched once more if it was deleted since. With `pick: true`, nothing found asks your app to pick one of the active records instead of ending `not_found`. With `adds: true`, the caller adds the record when no near match is it, so the question's none choice reads `None of these: add "<name>" as new` (`ensure` and `add_transaction` send it when they have an add). It only finds: `ensure` and `upsert` write. | `ask` for a strong match or near matches: `use:1`, `use:2`, ... (one choice per match), `use` (a search, on a realtime or simulated connection), `none` (ends `not_found`) or `cancel`. `ask_detail` (or `ask_name`, for a list without an address) when more than 10 are alike: `narrow` (with an `account_number`, `city`, `postal_code` or fuller `name`), `none` or `cancel`, three times at most. With `pick`, `ask_pick`: `use:1`, `use:2`, ... (one choice per active record, up to 25), `use` (a search, on a realtime or simulated connection) or `cancel` | `found`, `picked`, `not_found`, `cancelled` |
> | `ensure` | Runs `resolve`, then sends your `add` (the body `POST /<kind>` takes; `kind` is the list it goes to, such as `customer` or `item_service`) when nothing is the record. A record of the add's own list with exactly the name is found and used without asking (on a realtime or simulated connection, before the add goes out); only a near match asks. A name already held in its name space (customers, vendors, employees and other names share one; every item type another) is a 3100: it finds what holds the name and asks. Allows five renames. Without an `add`, it creates nothing, and `resolve` asks your app to pick one of the active records instead. With `lookup: false` it sends the add without looking, for a caller that already did. | Through `resolve` (`ask_pick` without an `add`). `ask_use`: `use_existing`, `rename` (with `name`), `cancel`. `ask_rename` when another list holds the name: `rename`, `cancel` | `found`, `picked`, `created`, `created_renamed`, `used_existing`, `cancelled`; fails as `renames_exhausted` |
> | `upsert` | Finds a list record as `resolve` does (`kind`, the name and parent in your `add`, optional `list_id`, `fields` and `accept`) and sends your `update` to it with its ListID filled in; the engine reads the edit sequence first and asks before overwriting a record someone changed since. Nothing is the record: sends your `add` instead, looking once more if the name was taken in between. If that look finds nothing, another list of the name space holds the name (a vendor named like your customer), and `ensure` asks its question. | `resolve / ask`: `use:1`, ... (one per match), `use` (a search), `none` (then the add goes out) or `cancel`. `taken / ask_rename` when another list holds the name: `rename` (with `name`, five times at most) or `cancel` (`taken / ask_use` when an item of another type holds it: `use_existing` sends the update to that item). And the [conflict prompt](#conflicts) when the record changed after it was read | `updated`, `created`, `created_renamed`, `cancelled`; fails as `name_taken` when the update renames it to a name in use, or `renames_exhausted` |
> | `push_records` | Runs `upsert` once per entry of `records` -- at most 200, each the `kind`, `add`, `update`, `list_id`, `fields` and `accept` that `upsert` takes, records of different lists mixed -- one at a time, in order, in one run. A record QuickBooks refuses, one renamed five times to names all taken, or one that isn't a valid `upsert` input, is an entry of `items` with its `error`, and the next one goes. For a webhook per record, run `upsert` once per record instead. | Through `upsert`, for each record that asks | `pushed` (no record failed), `pushed_with_errors` (one or more did; the rest still went) |
> | `add_transaction` | Sends any transaction add (`operation`, such as `invoice.create`, and its `params`, lines included) once every list record it names exists, in two passes over its distinct references (the same item on five lines is one). First it finds each with `resolve`, writing nothing: every question comes here, and a cancel ends the run with nothing created and nothing more asked. An account, sales tax code, terms, price level or sales tax item that isn't there is your app's to pick. Customers, vendors, other names and items that aren't there and that your app sent no complete add for (none, or an item add without its accounts) are asked about in one question, `ask_missing`, a row each: create it (an item's type and accounts, where the add doesn't say them, are your user's to choose) or use one QuickBooks has. Then it creates what the first pass didn't find, a reference sent with a stored ListID that names nothing any more included, with `ensure`: a customer, vendor, employee or item from the add your app sent for it in `adds`, a class, payment method, ship method or customer message from its name alone. Each entry of `adds` is `{"kind": "item_service", "add": {...}}`, matched to a reference by full name, and may carry `fields` (`email`, `phone`, `account_number`, `city`, `postal_code`, `company_name`) and `accept` (`strong`), which `resolve` matches that reference with in the first pass; a reference without an entry is matched on its name alone, and only an exact match is used without asking. `references` changes those rules by kind (`ensure`, `create` or `must_exist`). A cancel at a name clash there stops the rest. Then the add goes out with every reference as its ListID, and a lost answer is [settled by the run](#lost-answers): the run stamps the add with a GUID, and looks for it by the reference number your app sent, which QuickBooks answers whole, or else among the transactions for the same customer or vendor and date, at most 50. One carrying the GUID is the add, and the run reads it whole by its TxnID and uses it. A whole answer without it (by reference number, or fewer than 50) means the add didn't go in, and the run sends it again. | Through `resolve` in the first pass, and `ensure`'s name clash questions in the second, for each reference that asks. `ask_missing` between them, once, for every record with no complete add: `proceed` (a `rows` field, [below](#a-table-of-rows)) or `cancel`. And `ask_about_add` when the run could not settle whether the add went in either way: `use_existing:1`, `use_existing:2`, ... (one choice per candidate, its TxnID QuBe Sync's: the run reads that transaction whole by it before ending `added`; by `option`, `use_existing` takes a `txn_id`, and one QuickBooks has no transaction for asks again, saying so in the message and in `data.not_found`, and the third ends `no_such_transaction`), `add_again`, `cancel`. Its `data.why` says which: `unsettled`, the `candidates` the check found could be it (`candidates_complete` is true when they're every transaction that could be, so if none is it, it wasn't added); `no_response`, the check went unanswered too, so there are no candidates and nothing is known | `added`, `cancelled`; fails as `unanswered_twice` or `no_such_transaction` |
> | `resolve_transaction` | Finds a transaction by the `txn_id` your app stored, then by its `ref_number`, then by the filters your `query` carries (a customer, a date range), picking out the one carrying the `external_guid` your app kept from the add. One transaction with the number and no GUID to check is it; anything less sure is offered to your app. Without line items: read it whole by `txn_id` when you need them. | `ask`: `use:1`, `use:2`, ... (one choice per candidate), `none` (ends `not_found`) or `cancel` | `found`, `picked`, `not_found`, `cancelled` |
> | `void_transaction` | Finds a transaction of `kind` as `resolve_transaction` does, then voids it, or deletes it when `action` is `delete`. QuickBooks voids sixteen types (invoice, bill, check, journal\_entry and the others `kind` lists) and deletes those and nine more (sales\_order, estimate, purchase\_order, receive\_payment, time\_tracking and four others), so a delete takes kinds a void does not; asking to void one of those nine fails at once, before anything is sent, and says to pass `action` `delete`. An invoice that payments paid can be voided: the payments stay, with what they had applied to it now their `unused_payment`. Nothing is voided or deleted until the transaction is identified. | Through `resolve_transaction` | `voided`, `deleted`, `not_found`, `cancelled` |
> | `record_payment` | Finds an invoice as `resolve_transaction` does and records a `payment` (a `receive_payment.create` body: how it was paid) for the invoice's own customer, applied to that invoice; the amount defaults to what the invoice still owes. An invoice already paid in full is reported, not paid again. QuickBooks refuses a payment over the invoice's balance, so the run asks first. When no invoice is found and the `payment` names its `customer_ref`, it lists that customer's open invoices (up to 25) and asks; without a `customer_ref` it ends `not_found`. | Through `resolve_transaction`. `ask_over` when the amount is more than the invoice owes: `apply_owed` (the balance applied, the rest left on the payment as the customer's credit), `unapplied` (the whole payment, applied to nothing) or `cancel`. `ask_invoice` when the invoice isn't there: `use:1`, `use:2`, ... (one choice per open invoice, which then goes through the same check), `unapplied` (with an optional `total_amount` when the `payment` has none) or `cancel` | `applied`, `applied_with_credit`, `unapplied`, `already_paid`, `not_found`, `cancelled` |
> | `stream_query` | Pages through the `query` you pass, 200 at a time, and sends each page to your app as a `page` [notification](#sending-your-app-data) carrying `kind`, `page`, `more`, `pass` and `records`, then how many there were. Any query: every customer, the items changed since a date, one customer's invoices; one QuickBooks doesn't page, such as the accounts, arrives as a single page. If the Web Connector session ends between pages, it starts again from page 1 (`pass` 2, at most 3), so take a repeated page without harm. Needs a `webhook_url`. | No | `completed` |
> | `pull_changes` | The sync. For each kind word in `lists` (`customer`, `vendor`, `account`, `class`, the `item_*` kinds such as `item_service`, and every other list type a deleted-records query knows, all but `billing_rate`) and in `transactions` (`invoice`, `receive_payment`, `bill` and every other transaction type), runs `stream_query` over the records modified on or after `since`, inactive ones included, so they arrive as `page` [notifications](#sending-your-app-data) with that `kind`. Then it sends one `deleted` notification with the records of those kinds deleted since then, and of no others: a payment deleted since then isn't in it unless `transactions` names `receive_payment`. QuickBooks keeps 90 days of them. Without `since` it sends every record of those kinds, and no `deleted` notification: the initial import. Transactions come without their lines unless `include_line_items` is true (four kinds don't take it: `build_assembly`, `charge`, `time_tracking` and `vehicle_mileage`). A list QuickBooks doesn't page arrives as one page. Needs a `webhook_url`. | No | `completed` |
> | `company_profile` | Reads the company (name, legal name, address, fiscal year), its preferences (inventory, class tracking, multiple currencies, sales tax and the defaults QuickBooks posts with) and its host (product, version, country and the qbXML versions it speaks), all three in one Web Connector session. Run it when a connection is set up. | No | `completed` |
>
> 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`.

> [accordion] What each template returns in output
>
> | Template | `output` |
> | - | - |
> | `resolve` | `found` and `picked`: `{record, how, list}`, the record read whole, `how` it was found (`id`, `exact`, `strong` or `picked`) and the `list` it's in (`customer`, `item_service`, ...). `not_found` and `cancelled`: `{}` |
> | `ensure` | `found`: `{record, how, list}`, `how` as `resolve` gives it. `picked`, `created`, `created_renamed` and `used_existing`: `{record, list}`, the record picked, created or used and the `list` it's in: the `kind` it was added to, or the list of the record found or used (for an item, maybe another type than `kind`). `cancelled`: `{}` |
> | `upsert` | `updated`: `{record, matched, how}`, `matched` being `found`, `picked` or `used_existing`. `created` and `created_renamed`: `{record}`. `cancelled`: `{}` |
> | `push_records` | `{items, counts}`. `items` is one entry per record, in the order of `records`: its `index` (from 0), `name`, `outcome` (`upsert`'s: `updated`, `created`, `created_renamed`, `cancelled`, or how it failed: `failed`, `name_taken`, `renames_exhausted`), and `record` (with `matched` and `how` after an update) or, for one that failed, `error`. `counts` has `total`, `failed` and one count per outcome |
> | `resolve_transaction` | `found` and `picked`: `{record, ref: {txn_id, ref_number, external_guid}}`, the header, without lines. `not_found` and `cancelled`: `{}` |
> | `void_transaction` | `voided` and `deleted`: `{ref: {txn_id, ref_number, external_guid}}`. `not_found` and `cancelled`: `{}` |
> | `record_payment` | `applied` and `applied_with_credit`: `{payment, invoice: {txn_id, ref_number, balance_before, applied}}`; after `applied_with_credit`, `applied` is the balance and `payment.unused_payment` the credit. `unapplied`: `{payment}`. `already_paid`: `{invoice}`. `not_found` and `cancelled`: `{}` |
> | `stream_query` | `{kind, records, pages, passes}`: how many records were sent, over how many pages, and how many times the query was started from page 1 |
> | `pull_changes` | `{lists, transactions, deleted: {lists, transactions}}`: `lists` and `transactions` hold `stream_query`'s output for each kind, in the order given (`transactions` is `[]` when none were asked for), and `deleted` how many deleted records of each were sent (0 without `since`) |
> | `add_transaction` | `{record, references}`: `record` is the transaction, lines and their `txn_line_id`s included: what QuickBooks answered the add with; or, when its answer was lost and the run found it by its GUID, that transaction read by its TxnID; or the one your app named at `ask_about_add`, read the same way. Only when the read after a found GUID can't be answered is it the header the check found. `references` is an object keyed by where each reference sits in the `params` your app sent: `customer_ref`, `invoice_line_add.0.item_ref` (the first line's item). An item named on two lines is under both paths, with the same entry. Each entry is `kind` (the list it names), `outcome`, `how` (`id`, `exact`, `strong` or `picked`, when the first pass found it) and `record`, the record the add used, read whole, whose `type` is always its list (`customer`, `item_service`, ...), however it was found or made. Store each `record.list_id` against what your app sent at that path: it differs from the `list_id` you sent when the one you stored was stale. `outcome` is `found` or `picked` (the first pass, or a row of `ask_missing` answered `use`), `created`, `created_renamed` or `used_existing` (the second), or `cancelled`. On `cancelled`, `record` is null, a reference the second pass didn't get to is still `not_found`, and one the run never looked for has a null `outcome` and `record` |
> | `company_profile` | `{company, preferences, host}`, each as its query answers it |
>
> An invoice whose customer the run created under a new name, abridged:
>
> ```json add_transaction output (added) theme={null}
> {
>   "record": {
>     "txn_id": "3-1792189814", "ref_number": "1045",
>     "customer_ref": { "list_id": "8000002B-1792189814", "full_name": "Lone Star Supply Co (customer)" },
>     "invoice_line_ret": [
>       { "txn_line_id": "8L-1792189814", "item_ref": { "list_id": "80000025-1792189712", "full_name": "Vinyl Banner" },
>         "quantity": 2.0, "rate": 180.0, "amount": 360.0 },
>       …
>     ],
>     …
>   },
>   "references": {
>     "customer_ref": { "kind": "customer", "outcome": "created_renamed", "how": null,
>                       "record": { "list_id": "8000002B-1792189814", "full_name": "Lone Star Supply Co (customer)",
>                                   "type": "customer", … } },
>     "invoice_line_add.0.item_ref": { "kind": "item", "outcome": "found", "how": "exact",
>                                      "record": { "list_id": "80000025-1792189712", "full_name": "Vinyl Banner",
>                                                  "type": "item_non_inventory", … } },
>     …
>   }
> }
> ```
>
> 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](https://docs.qubesync.com/cli.md) 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](https://mintcdn.com/qubesync/0vBf1cM4bTpV_SLq/images/workflows/workflows-templates.png?fit=max\&auto=format\&n=0vBf1cM4bTpV_SLq\&q=85\&s=86c21edf5fab45a138fbffb91718a94d)

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](https://docs.qubesync.com/webhooks.md#securing-your-endpoints)), and your endpoint answers the same way:
any `2xx` within 5 seconds.

> [card] You start it
>
> `POST /workflow-runs` answers `201`, with the run `running`.

> [card] While it runs
>
> `awaiting_input` when it needs a decision, `notification` when it sends your app data.

> [card] Once, at the end
>
> `completed`, `failed` or `cancelled`.

| Event | When | Your app |
| - | - | - |
| `workflow_run.awaiting_input` | The run needs a decision. It carries the question in `prompt`. | Shows its `details` and `choices`, then POSTs the picked `choice` to `links.decisions` ([below](#answering-a-question)). |
| `workflow_run.notification` | A `notify` state [sends your app data](#sending-your-app-data). The run waits for your `2xx`. | Stores `data`, deduplicating on `delivery_id`. |
| `workflow_run.completed` | The run ended as designed: `completed`, or a success outcome the chart declares. | Branches on `outcome` and stores `output`. |
| `workflow_run.failed` | Something needs attention: a QuickBooks error the chart doesn't handle, a named failure such as `renames_exhausted`, or a limit reached. | Shows `error.message`. What the run changed stays in QuickBooks. |
| `workflow_run.cancelled` | Somebody stopped it: your app with `POST links.cancel`, or a person on the run's page. | Reads `cancel_reason`. Nothing is undone. |

Every event has `event`, `timestamp`, `app_id`, `sandbox`, `connection_id`, `workflow` (the key),
`workflow_id` (`null` once the workflow is deleted), `run_id`, `reference` (yours, as you sent it; `null`
without one), `revision`, `run_state` (the run's `state` when the body was built), `headline` (one plain sentence saying
what happened; never parse it) and `links` (`run` and `ui` always, `cancel` until the run ends, `decisions`
and `search` only while it asks, `search` only when the question has a record to search for).
`workflow_run.completed` carries `outcome`, `output` (only the keys that ending sets, none null) and
`summary`; `workflow_run.failed` carries `outcome`, `error` and `summary`, and `output` only when its failure
outcome declares one; `workflow_run.cancelled` carries `outcome` (null), `cancel_reason` and `summary`. An
ending's `summary` has `ended_at`, `changes` (`action`, `kind`, the record's ids, `item`) and `decisions` (`question`, `choice`, `at`, `item`), `stopped_at`
on a failure or a cancel, and `unconfirmed` and `notifications` when there are any; the run's own summary
also has `side_effects`, `requests`, `calls`, `matches` and `discarded`. A run's `error` is an object:
`code` (QuickBooks' status code, or null), `type`, `message`, `quickbooks_message` and `hint`.
`GET links.run` returns the whole run, including its input, vars, trace and whole summary.

Deliveries can fail, repeat, or arrive early and out of order:

| Delivery | What happens | What to do |
| - | - | - |
| **Retries** | A failed delivery is retried with backoff for about 8 hours, each attempt logged in the run's event log as `webhook_sent`. If they all fail, your organization's owners get an email. Notifications are retried on their own schedule, for about an hour while the run waits ([below](#sending-your-app-data)). | Missed one? [`POST .../workflow-runs/{run_id}/redeliver`](https://docs.qubesync.com/webhooks#when-a-delivery-fails) (or **Resend latest event** on the run's page) sends the run's latest event again, its ending or the question it's asking now, with its current `revision`, so your revision check still holds. Notifications aren't resent. Or read the run with `GET links.run`. |
| **Early and out of order** | Deliveries can repeat, cross each other, and reach you before the response to the call that caused them. A run that asks or ends as it starts can send `workflow_run.awaiting_input` or its ending before `POST /workflow-runs` answers `201` (a match answered from the [name index](https://docs.qubesync.com/matching.md) asks within milliseconds), and a decision that ends the run, such as a cancel, can send its ending before your POST is answered. Each delivery is built when it's sent, from the run as it is then: `run_state` says where the run is, and a `workflow_run.awaiting_input` still being retried once the question is answered, or the run has ended, isn't sent. Every body of a run, a webhook, the `201`, a decision's response or a `GET`, carries its `revision`, which goes up with every change. A run ends only once. | Save your record with a `reference` before you start the run, a new one for every run, and find it by `reference`, not `run_id`. Keep the highest `revision` you've applied, and skip a body whose `revision` isn't higher. A notification is data, not the run's state: deduplicate it on `delivery_id`, and never skip one by revision. |

> [accordion] Handle them in a job
>
> WorkflowRunEventJob, in Ruby
>
> If one endpoint takes both request and run webhooks, [route on `event`](https://docs.qubesync.com/webhooks#handle-it-in-a-job)
> and handle the run's in a job of its own:
>
> ```ruby theme={null}
> # app/jobs/workflow_run_event_job.rb
> class WorkflowRunEventJob < ApplicationJob
>   def perform(event)
>     # Saved with its reference before the run started, so an event that beats the 201 finds it.
>     sync = event["reference"] && OrderSync.find_by(qube_reference: event["reference"])
>     return unless sync # a run your app didn't start, such as one from the dashboard
>
>     if event["event"] == "workflow_run.notification"
>       # Data, not the run's state: a retry repeats its delivery_id. Never skip one by revision.
>       return if sync.deliveries.exists?(delivery_id: event["delivery_id"])
>
>       ImportPage.call(sync, event["name"], event["data"]) # e.g. page, with data["kind"]
>       sync.deliveries.create!(delivery_id: event["delivery_id"])
>     else
>       ApplyRun.call(sync, event)
>     end
>   end
> end
>
> # Any body of the run: a webhook, the 201's or a decision's response `data` (which
> # names the id and state `id` and `state`), or a GET.
> class ApplyRun
>   def self.call(sync, body)
>     sync.with_lock do
>       # Older than what you have: a repeat, or one that crossed a response.
>       next if body["revision"] <= sync.qube_revision
>
>       sync.update!(
>         qube_run_id: body["run_id"] || body["id"],
>         qube_revision: body["revision"],
>         status: body["run_state"] || body["state"],
>         question: body["prompt"], # nil once it's answered
>         outcome: body["outcome"],
>         output: body["output"],
>         error: body.dig("error", "message") || body["cancel_reason"]
>       )
>     end
>   end
> end
>
> # Starting it: the record first, then the run (Qube.api is the small client in Making requests).
> # A reference of its own for every run, not the order's id: revisions count per run, so a second
> # run for this order, found by a shared reference, would land on the first run's row and be skipped.
> sync = OrderSync.create!(order: order, qube_reference: "order-#{order.id}-#{SecureRandom.uuid}",
>                          qube_revision: 0)
> run = Qube.api(:post, "/connections/#{connection_id}/workflow-runs",
>                { workflow: "add_transaction", input: input,
>                  reference: sync.qube_reference, webhook_url: webhook_url })
> ApplyRun.call(sync, run)
> ```

### 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`.

| Choice field | What it holds |
| - | - |
| `id` | Unique within the prompt. Send it as `choice`. |
| `label` | The button's text. |
| `description`, `detail` | `null`, or a line under the label: what picking it does, or which record it is (a candidate's list, why it matched, and its email and phone when QuickBooks has them, such as `similar name · ap@riverbend.example · 512-555-0142`; a transaction's date, customer and amount). |
| `fields` | What to fill in first, each `name`, `type` (`string`, `number`, `boolean`, `record` or `rows`), `label` and `required`; for a `record`, `kind`; for one that takes a fixed list, `enum` (`[{"value", "label"}]`, a drop-down); for `rows`, `columns` and `rows` ([below](#a-table-of-rows)). Empty for a choice that's a button alone. |

> [card] 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.

> [card] 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"}}`.

Search for the record a person is typing

<pre>`GET 
          /api/v2`</pre>

Search for the record a person is typing:

```http theme={null}
GET /api/v2/connections/{connection_id}/workflow-runs/{run_id}/search?prompt_token=Vq3kT9xRb2LmW8sZ&field=list_id&q=Lone
```

```bash theme={null}
curl -u "$QUBE_API_KEY:" \
  -G \
  "https://qubesync.com/api/v2/connections/$CONN_ID/workflow-runs/$RUN_ID/search" \
  --data-urlencode "prompt_token=Vq3kT9xRb2LmW8sZ" \
  --data-urlencode "field=list_id" \
  --data-urlencode "q=Lone"
```

It answers at most 20 records of the field's `kind`, best first, from the connection's [name index](https://docs.qubesync.com/matching.md). `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:

```json theme={null}
{
  "data": [
    { "value": "80000012-1712345678", "label": "Lone Star Supply", "detail": "Customer · ar@lonestar.example · 512-555-0142 · Austin 78701" },
    { "value": "80000031-1712399999", "label": "Lone Star Supply Co (old)", "detail": "Customer · inactive" }
  ]
}
```

`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.

| Field | What it holds |
| - | - |
| `columns` | Each `name`, `type` (`string`, `number`, `boolean` or `record`), `label`, `required`, `kind` (a `record` column's list, or `null` when it depends on the row), `enum` (the values it takes, or `null`) and `when` (`null`, or the values of the row's other columns it applies under: `{"action": ["create"], "item_type": ["item_inventory"]}`). |
| `rows` | Each `key` (what its answer is keyed by), `label`, `detail`, `columns` (the names it asks), `values` (what it already says for columns it doesn't ask, such as an item add's type) and `kinds` (each record column's list for this row). At most 100. |

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:

```json theme={null}
{
  "choice": "proceed",
  "data": {
    "records": {
      "customer_ref": { "action": "use", "list_id": "80000001-1712345678" },
      "invoice_line_add.0.item_ref": {
        "action": "create", "item_type": "item_inventory",
        "income_account": "8000000A-1712345678", "asset_account": "80000005-1712345678",
        "cogs_account": "8000000C-1712345678"
      },
      "invoice_line_add.1.item_ref": { "action": "create", "income_account": "8000000A-1712345678" }
    }
  }
}
```

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`.

| `prompt.kind` | Asked | Choices |
| - | - | - |
| `decision` | At a `wait` state | The chart's options, such as `use_existing`, `rename` and `cancel`, and one per record it offers |
| `conflict` | When an update meets a record someone changed after it was read ([Conflicts](#conflicts)) | Always `overwrite` and `keep`, and a `details` row per field that differs |

> [accordion] Both prompts, in full
>
> ```json kind: decision (abridged) theme={null}
> {
>   "id": "ask_use",
>   "kind": "decision",
>   "message": "\"Riverbend Electric\" is already in QuickBooks. Use that record, add this one under another name, or cancel.",
>   "data": {
>     "kind": "customer",
>     "name": "Riverbend Electric",
>     "existing": { "list": "customer", "list_id": "8000001F-1790470623", "edit_sequence": "1790470623",
>                   "full_name": "Riverbend Electric", "company_name": "Riverbend Electric LLC",
>                   "email": "ap@riverbend.example", "phone": "512-555-0142", "is_active": true }
>   },
>   "details": [
>     { "label": "Customer", "value": "Riverbend Electric" },
>     { "label": "Held by", "value": "Customer" }
>   ],
>   "options": [ … the three answers as the chart declares them … ],
>   "choices": [
>     { "id": "use_existing", "label": "Use the existing record", "detail": null,
>       "description": "Carry on with the record QuickBooks already has. Nothing new is created.", "fields": [] },
>     { "id": "rename", "label": "Add it under another name", "detail": null,
>       "description": "Try the add again under a name you choose.",
>       "fields": [ { "name": "name", "type": "string", "label": "New name", "required": true } ] },
>     { "id": "cancel", "label": "Cancel", "detail": null,
>       "description": "Stop here. Nothing is created.", "fields": [] }
>   ]
> }
> ```
>
> ```json kind: conflict (abridged) theme={null}
> {
>   "id": "conflict:update_contact",
>   "kind": "conflict",
>   "state": "update_contact",
>   "message": "customer \"Riverbend Electric\" changed in QuickBooks after it was read; 1 of the 2 fields you are updating differs.",
>   "data": {
>     "operation": "customer.update",
>     "sent": { "phone": "512-555-0100", "email": "billing@riverbend.example" },
>     "current": { "list_id": "8000001F-1790470623", "edit_sequence": "1790470711", "full_name": "Riverbend Electric",
>                  "phone": "512-555-0199", "email": "ap@riverbend.example", "last_name": "Okafor-Reyes" },
>     "as_read": { "list_id": "8000001F-1790470623", "edit_sequence": "1790470623", "full_name": "Riverbend Electric",
>                  "phone": "512-555-0142", "email": "ap@riverbend.example", "last_name": "Okafor" },
>     "conflicts": [
>       { "field": "phone", "sent": "512-555-0100", "current": "512-555-0199", "as_read": "512-555-0142" }
>     ],
>     "theirs": [
>       { "field": "last_name", "as_read": "Okafor", "current": "Okafor-Reyes" }
>     ],
>     "position": 0
>   },
>   "details": [
>     { "label": "Phone", "value": "yours 512-555-0100 · theirs 512-555-0199" }
>   ],
>   "options": [
>     { "id": "overwrite", "label": "Apply my changes anyway",
>       "fields": { "params": { "type": "object", "required": false, "label": "Different fields to send instead" } } },
>     { "id": "keep", "label": "Keep what QuickBooks has" }
>   ],
>   "choices": [
>     { "id": "overwrite", "label": "Apply my changes anyway", "description": null, "detail": null, "fields": [] },
>     { "id": "keep", "label": "Keep what QuickBooks has", "description": null, "detail": null, "fields": [] }
>   ]
> }
> ```
>
> 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.

> [accordion] Answering a conflict
>
> | Prompt field | What it holds |
> | - | - |
> | `id` | `conflict:<state>`, or `<calling state> / conflict:<state>` when a workflow this one calls asked (`prompt.from` then names it). |
> | `state` | The request state that sent the update, in the workflow that asked. |
> | `data.operation` | The update, such as `customer.update`. |
> | `data.sent` | The fields it sends, without its id and edit sequence. |
> | `data.current` | The record as QuickBooks has it now, with its edit sequence, but without its lines, linked transactions and data extensions, which the run's read of it doesn't include either. |
> | `data.as_read` | The record as the run read it, or `null` when the chart passed its own `edit_sequence`. |
> | `data.conflicts` | One row per field being updated that someone else changed too: `field`, `sent`, `current`, `as_read`. |
> | `data.theirs` | What they changed that the update doesn't send: `field`, `as_read`, `current`. Empty when `as_read` is `null`. |
> | `data.position` | Which of the state's requests it was, counting from 0. Always 0 unless the state sends several. |
>
> Send my fields anyway, with the current edit sequence
>
> <pre>`POST 
>           /api/v2`</pre>
>
> Send my fields anyway, with the current edit sequence:
>
> ```http theme={null}
> POST /api/v2/connections/{connection_id}/workflow-runs/{run_id}/decisions
>
> {"choice": "overwrite"}
> ```
>
> ```bash theme={null}
> curl -u "$QUBE_API_KEY:" \
>   -X POST \
>   "https://qubesync.com/api/v2/connections/$CONN_ID/workflow-runs/$RUN_ID/decisions" \
>   -H "Content-Type: application/json" \
>   -d '{"choice": "overwrite"}'
> ```
>
> Send these fields instead, by option: a merge worked out from the diff
>
> <pre>`POST 
>           /api/v2`</pre>
>
> Send these fields instead, by option: a merge worked out from the diff:
>
> ```http theme={null}
> POST /api/v2/connections/{connection_id}/workflow-runs/{run_id}/decisions
>
> {
>   "option": "overwrite",
>   "data": {
>     "params": {"phone": "512-555-0199", "email": "billing@riverbend.example"}
>   }
> }
> ```
>
> ```bash theme={null}
> curl -u "$QUBE_API_KEY:" \
>   -X POST \
>   "https://qubesync.com/api/v2/connections/$CONN_ID/workflow-runs/$RUN_ID/decisions" \
>   -H "Content-Type: application/json" \
>   -d '{
>     "option": "overwrite",
>     "data": {
>       "params": {"phone": "512-555-0199", "email": "billing@riverbend.example"}
>     }
>   }'
> ```
>
> Leave QuickBooks as it is
>
> <pre>`POST 
>           /api/v2`</pre>
>
> Leave QuickBooks as it is:
>
> ```http theme={null}
> POST /api/v2/connections/{connection_id}/workflow-runs/{run_id}/decisions
>
> {"choice": "keep"}
> ```
>
> ```bash theme={null}
> curl -u "$QUBE_API_KEY:" \
>   -X POST \
>   "https://qubesync.com/api/v2/connections/$CONN_ID/workflow-runs/$RUN_ID/decisions" \
>   -H "Content-Type: application/json" \
>   -d '{"choice": "keep"}'
> ```
>
> 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`.

| If | What happens |
| - | - |
| A delivery fails | **Delivery is at least once.** A failed delivery is retried up to 10 times over about an hour, with the same `delivery_id`. If none succeeds, your organization's owners get an email and the state raises `error` with `response.error.type` `not_delivered`, which the chart can handle (go to a `wait` that asks whether to retry, say); otherwise the run fails. |
| A notification is over 4 MB, once filled in | It's never sent: `error` `too_large`. Send fewer records at a time with a smaller `page_size`. |
| The run has no `webhook_url` | Notifications go only to the run's `webhook_url`: a chart can't name a URL of its own. A workflow with a `notify` state, or one that calls such a workflow, refuses to start without one (`422 webhook_url_required`). |

> [!NOTE]
>
> **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).

> [accordion] The notification payload
>
> delivery\_id, seq, name, state, from, data
>
> A notification has the fields [every event has](#what-your-app-receives), plus:
>
> | Field | |
> | - | - |
> | `delivery_id` | The same on every retry of this notification. Deduplicate on it. |
> | `seq` | 1, 2, 3… across the run, the workflows it calls included. |
> | `name` | The notify state's `name`, such as `page`. Route on it. |
> | `state` | The path of the state that sent it, such as `pull_lists / send` when a workflow the run calls sent it. The run's own state is `run_state`. |
> | `from` | `null` when the run's own workflow sent it. Otherwise the called workflow and its state, `{"workflow", "state"}`, plus `item_index` and `item_count` in a state that runs once per item. |
> | `data` | The state's `data`, filled in. |
>
> ```json theme={null}
> {
>   "event": "workflow_run.notification",
>   "timestamp": 1758000000,
>   "app_id": "018f0000-0000-7000-8000-000000000003",
>   "sandbox": false,
>   "connection_id": "018f0000-0000-7000-8000-000000000001",
>   "workflow": "pull_changes",
>   "workflow_id": "018f0000-0000-7000-8000-000000000004",
>   "run_id": "018f0000-0000-7000-8000-000000000000",
>   "reference": "nightly-sync-2026-10-11",
>   "revision": 5,
>   "run_state": "running",
>   "links": {"run": "https://qubesync.com/api/v2/connections/…/workflow-runs/…", "decisions": "…", "cancel": "…", "ui": "…"},
>   "delivery_id": "018f0000-0000-7000-8000-000000000005",
>   "seq": 1,
>   "name": "page",
>   "state": "pull_lists / send",
>   "from": {"workflow": "stream_query", "state": "send", "item_index": 0, "item_count": 3},
>   "data": {"kind": "customer", "pass": 1, "page": 1, "more": true,
>            "records": [{"list_id": "80000001-1738310400", "full_name": "Acme Co"}]}
> }
> ```

To write a notify state of your own, see [Build your own workflow](https://docs.qubesync.com/workflow-authoring.md#sending-your-app-data-from-a-chart).
[Pagination](https://docs.qubesync.com/pagination.md#an-initial-import) says when to use a workflow for an import and when the plain
paginated API.

### How a run ends

| Run `state` | What it means |
| - | - |
| `completed` | The job went as designed. Branch on `outcome`: `completed`, or one the chart declares, such as `created`. |
| `failed` | Something needs attention. `outcome` is `failed` or a named failure such as `renames_exhausted`, and `error` says why. |
| `cancelled` | Someone stopped it, with an optional `cancel_reason`. Nothing is undone. |

A chart's own `cancelled` ending, when your app answered `cancel`, is a *completed* run: nothing is wrong.

> [accordion] Every ending's fields, and cancelling in detail
>
> | `state` | `outcome` | `output` | `error` |
> | - | - | - | - |
> | `completed` | `completed`, or a success outcome the chart declares: `created`, `used_existing`, `cancelled`... | The chart's `output`, or the outcome's own | `null` |
> | `failed` | `failed`, or a named failure the chart declares, such as `renames_exhausted` | `null`, unless the named failure declares an `output` | What went wrong, in words |
> | `cancelled` | `null` | `null` | `null`; your reason is in `cancel_reason` |
>
> 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](https://docs.qubesync.com/error_handling.md#when-the-answer-is-lost)). 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.

> [!TIP]
>
> 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`](https://docs.qubesync.com/simulator#make-it-fail) 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](https://docs.qubesync.com/workflow-authoring.md#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.

| What it finds | What the run does |
| - | - |
| The record already has what the update sends | Nothing to do. |
| The other person changed only fields the update doesn't send | Sends the update again with the current edit sequence, without asking. |
| They changed a field the update sends | Pauses and asks your app with a `conflict` [prompt](#two-kinds-of-question). |

If you want to build a chart that overwrites or fails
instead of asking, see [Conflicts in a chart](https://docs.qubesync.com/workflow-authoring.md#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:

```bash theme={null}
bin/rails g qube_rails:workflows          # Rails: app/qube/qube_workflows.rb and sig/qube_workflows.rbs
QUBE_API_KEY=... qube_sync workflows      # any Ruby app: lib/qube_workflows.rb and sig/qube_workflows.rbs
QUBE_API_KEY=... qubesync workflows       # Python: qube_workflows.py, TypedDicts
QUBE_API_KEY=... npx qubesync workflows   # Node: qube-workflows.js and qube-workflows.d.ts
```

In Python it's `add_transaction.invoice(...)` and in Node `addTransaction.invoice(...)`, and the input
types have the same names in both (`AddTransactionInvoiceInput`).

```ruby theme={null}
QubeWorkflows.add_transaction.invoice(
  connection, { customer_ref: { full_name: "Riverbend Electric" }, invoice_line_add: [...] },
  adds: [{ kind: "customer", add: { name: "Riverbend Electric" } }],
  reference: "invoice-1042-#{SecureRandom.uuid}", webhook_url: webhook_url
)
```

`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](https://mintcdn.com/qubesync/0vBf1cM4bTpV_SLq/images/workflows/runs-index.png?fit=max\&auto=format\&n=0vBf1cM4bTpV_SLq\&q=85\&s=14bf2d6b8a739c6daa6b77c8825318d0)

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](https://mintcdn.com/qubesync/0vBf1cM4bTpV_SLq/images/workflows/run-completed.png?fit=max\&auto=format\&n=0vBf1cM4bTpV_SLq\&q=85\&s=4927428ab2798afecead257cc90f9a91)

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](https://docs.qubesync.com/api-reference/v2/workflows/start-a-workflow-run.md) has every field.

| Call | What it does |
| - | - |
| `POST /workflow-runs` | Starts a run: `workflow` (the key), `input`, optional `reference` (your own label for the run, 1 to 255 characters, on the run and every webhook) and `webhook_url`. On a sandbox or simulated connection, `version` picks the chart ([versions](https://docs.qubesync.com/workflow-authoring.md#versions-and-publishing)). `201` with the run. Input the workflow doesn't accept is `422`, with the path of each problem, before anything is queued. A `standard` connection is `422 realtime_required` ([above](#workflows-run-on-realtime-connections)). |
| `GET /workflow-runs/{run_id}` | The whole run: `reference`, `revision`, `state`, `current_state`, `outcome`, `input`, `vars`, `prompt`, `output`, `error`, `summary`, `cancel_reason`, `trace` and `links`. |
| `GET /workflow-runs` | The 100 most recent runs your app started on the connection, newest first. `?state=`, `?outcome=` (the exact key) and `?reference=` (exactly as you sent it) filter them. |
| `POST /workflow-runs/{run_id}/decisions` | Answers the question: `choice` (one of `prompt.choices`), plus `data` with the choice's fields, or `option` with the option's. `links.decisions` names the question (`?prompt_token=`, the prompt's `token`; or send `prompt_token` in the body) so a retry can't answer the next one. `422` for a choice or option it doesn't offer, a missing field, a field the choice doesn't list, a conflict's `params` with a field the update doesn't take (`invalid_data`), or a `prompt_token` that isn't a string. `409 prompt_changed` when the run has asked again since the question the token names. `400 not_awaiting_input` when the run isn't waiting. Both are safe to ignore on a repeat. Every refusal is `{"errors": {"code": "prompt_changed", "detail": "..."}}`: branch on `code`, not on `detail`'s words. |
| `POST /workflow-runs/{run_id}/redeliver` | Sends the run's latest event again, its ending or the question it's asking now, with its current `revision` ([When a delivery fails](https://docs.qubesync.com/webhooks.md#when-a-delivery-fails)). `202`. `409 nothing_to_redeliver` while it's working or when it has no `webhook_url`; `429 rate_limited` past one a minute per run or 60 a minute per app, with `Retry-After`. |
| `POST /workflow-runs/{run_id}/cancel` | Stops the run, with an optional `reason`. `409 already_finished` once it has ended. |
| `GET /workflow-runs/{run_id}/events` | The event log, oldest first, the workflows it calls included. Page with `?after=` (the previous page's `next_after`, or the last `seq` you have) and `?limit=` (up to 500, default 100). |
| `DELETE /workflow-runs/{run_id}` | Deletes a finished run before retention does. `409 run_not_finished` while it's running or waiting. |

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

> [accordion] Starting a run twice runs it twice
>
> unless both calls carry the same Idempotency-Key
>
> Use an `Idempotency-Key` to make sure you're not starting duplicate runs. See [Retrying a call safely](https://docs.qubesync.com/making-requests.md#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.

> [accordion] Answer at links.decisions, and a retry is safe
>
> Each question a run asks has a `prompt.token` to distinguish between questions that look the same (e.g. a `wait` in a loop, or a workflow called once per item), and `links.decisions` carries it (`?prompt_token=…`). It serves as an idempotency key of its own: if your answer gets no response, you can safely send it again to the same link without accidentally answering the run's next question. Once the run has asked again, it's refused with `409 prompt_changed` and nothing changes. On a retry, a `409` or a `400 not_awaiting_input` means your first answer landed or someone answered on the run's page. If you build the URL yourself, send the token as `prompt_token` in the body; without one, an answer is taken for whatever the run is
> asking when it arrives.
>
> The run goes on as soon as your answer lands, so the webhook it sends next (the next question, or `workflow_run.completed` after a cancel) can arrive before your POST is answered. The response carries the run's `revision` as webhooks do: apply whichever is newer ([early and out of order](#what-your-app-receives)).

> [accordion] Workflows are composable
>
> 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.

> [accordion] A chart can identify a record by name
>
> the match state
>
> 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](https://docs.qubesync.com/matching.md#in-a-workflow) 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](https://docs.qubesync.com/workflow-authoring.md#matching-names-in-a-chart).

> [accordion] An installed template is a copy
>
> 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.

> [accordion] Production runs the published version
>
> 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](https://docs.qubesync.com/workflow-authoring.md#versions-and-publishing).

> [accordion] Limits
>
> | Limit | Value |
> | - | - |
> | Transitions per run | 500 |
> | `always` transitions in a row | 50 |
> | Items in one `each` | 200, run one at a time, in order |
> | Candidates in one match answer | 10 (`candidates_total` says how many qualified) |
> | One notification's `data` | 4 MB |
> | Delivering a notification | 10 attempts over about an hour |
> | Workflows calling workflows | 8 deep |
> | Settling one lost answer | 5 more requests, at most 2 of them re-sends |
> | Writes after a conflict | 3 |
> | Cancel reason | 500 characters |
> | Finished runs kept | 60 days (per-state details are trimmed after 7). Runs waiting on an answer are never removed. |
> | Runs listed per request | the 100 most recent |
> | Events per page | 500 (default 100) |

## Next

> [card] [Build your own workflow](https://docs.qubesync.com/workflow-authoring.md)
>
> Chart anatomy, the builder, writing charts with an AI assistant, lost answers and conflicts in a chart,
> versions, and promoting to production.

> [card] [API reference: Workflows](https://docs.qubesync.com/api-reference/v2/workflows/start-a-workflow-run.md)
>
> Every workflow, template and run endpoint.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.