Skip to main content
A QuickBooks Desktop integration has more to get right than most: questions only your user can answer, ids that are unique only within one company file, and answers that can be lost on the way back from your customer’s computer.

Audit your code with your coding agent

1

Download the audit skill

It’s one Markdown file: every rule below, with what to look for in your code and what getting it wrong and right looks like, and the steps to check them. It holds no key and calls nothing. From your app’s repo, save it where your agent looks for skills:
Both read skills from .agents/skills/:
2

Run it

Ask your agent to audit your QuBe Sync integration: an agent that reads skills finds this one by its description, and in Claude Code /qube-sync-audit runs it by name. For an agent without skills, say “Follow qube-sync-audit/SKILL.md and audit this repo.”The agent only reads your code. It never opens your .env or prints a secret, and it changes nothing until you ask. Its report lists each finding with its rule id, severity and file:line, critical first.
3

Check your webhook endpoint live

With your app running on your machine, the CLI sends your webhook endpoint signed test deliveries and grades its answers: a bad signature, an old timestamp, a path your app doesn’t route. Each is a delivery your app must refuse or can’t route, so none reaches a real run or connection. The audit skill runs it too, and folds what fails into its report:
Give it your app’s local webhook URL, the one qube listen --forward-to forwards to, not the relay’s. When your run webhook URLs don’t start with .../webhooks/qube/runs/, pass --operation with the segment yours start with. The command needs the CLI release that has qube check webhooks.
Your agent can also read these rules, or one area’s, from the MCP server with its get_integration_checklist tool. A Critical rule guards against data loss, a security hole or another customer’s data, and a Major one against wrong behaviour your users see.

Onboarding

Connecting a customer’s QuickBooks: the connection, its webhook and its events.

connection-webhook-url

Major Applies when the app connects its customers’ QuickBooks (it creates connections). Create each customer’s connection with a webhook_url, so QuBe Sync can tell your app when they’re connected. The setup ends on the QuickBooks computer and never comes back to your app: the connection’s webhook_url hearing connection.connected is how the app knows. Without one, the app shows the customer’s QuickBooks as waiting forever, and nothing tells it when requests stall. See Add the “Connect to QuickBooks” button and Connection events.

save-connection-id

Major Applies when the app connects its customers’ QuickBooks (it creates connections). Save the connection’s id on the record that keeps it (the customer’s account or shop, say) before you send them to its links.onboarding, and reuse it when they click Connect again. Every request and run goes out on that id, and the connection’s events are matched to it. A simulated connection is connected as it’s made, so its connection.connected can arrive before the create call returns. A customer who comes back (a link past its 48 hours, QuickBooks on a new computer) needs the same connection’s link again, not a second connection. See Add the “Connect to QuickBooks” button and Setting a connection up again.

workflow-connections-realtime

Major Applies when the app creates connections and starts workflow runs on them. Create the connections your app runs workflows on as realtime ("type": "realtime" on POST /api/v2/connections), and handle a start answered 422 with errors.code realtime_required. Workflows run on realtime and simulated connections only: a start on a standard connection is refused with nothing stored, so its customer never gets the run. A connection’s type can’t be changed through the API, only upgraded from its page in the dashboard, and once a downgrade takes effect new runs are refused (runs already going finish). The same call with the same Idempotency-Key starts the run once the connection is upgraded. See Workflows run on realtime connections and When a workflow run is refused.

connected-by-webhook

Major Applies when the app connects its customers’ QuickBooks (it creates connections). Show a customer’s QuickBooks as connected only when a signed connection.connected arrives for their connection, never when a browser comes back. The onboarding page doesn’t send your customer back to your app: the setup ends on the QuickBooks computer, which usually isn’t signed in to your app, and the v2 API refuses a redirect_url. Nothing has to wait for it either: runs started before the Web Connector connects wait in the queue. See Add the “Connect to QuickBooks” button and Connection events.

connection-event-for-saved-connection

Critical Applies when the app takes connection events. Apply a connection event only to the record whose saved connection id is the event’s connection.id, and answer it 2xx either way. The signature covers the body, not the URL. A URL that names the record keeping the connection (the qube_rails engine’s /webhooks/qube/connections/<id>) is only a pointer, so a signed event posted to another record’s URL would change that record. See Connection events and Add the “Connect to QuickBooks” button.

connection-event-newer-revision

Major Applies when the app takes connection events. Apply a connection event only when its revision is newer than the last you applied for that connection, and save the revision with the change. Deliveries can come out of order, a retried connection.stalled after its connection.resumed say. The revision counts up across all four events, per connection, and a retry of one event has the same delivery_id and revision. See Connection events.

show-stall-reason

Minor Applies when the app takes connection events. Show your customer what connection.stall_reason says to do, and clear it on connection.resumed. not_checking_in means the Web Connector hasn’t checked in when it was due: turn on the QuickBooks computer and check the Web Connector. quickbooks_unavailable means it is checking in but can’t reach QuickBooks: open QuickBooks with the company file. A stall can change its reason and send connection.stalled again, so show the newest. See Connection events and Keep an eye on the connection.

setup-cancelled-offers-connect

Minor Applies when the app takes connection events. On connection.setup_cancelled, offer the Connect button again. Your customer chose Cancel setup on the onboarding page. The connection is unchanged and its onboarding link still works, so Connect can take them back to the same one. See Connection events.

Calling the API and starting runs

The API key, the calls that queue requests or start workflow runs, and what each one carries.

api-key-on-the-server

Critical Applies to every integration. Call the API only from your server, with HTTP basic auth: the API key as the username and an empty password, read from the environment. An app’s key reaches every connection of that app. In a browser, a mobile app or the repo, anyone who sees it can read and write your customers’ company files. See Do it from your code.

sandbox-production-apart

Major Applies to every integration. Keep sandbox and production apart: each environment uses its own app’s API key and webhook secret, and only that app’s connection ids. Your code doesn’t change between the two; the API key, the webhook secret and the connection ids do. A sandbox key can’t reach production connections, and the other way round, so a connection id kept under one app answers 404 to the other’s key. Every webhook says which app sent it, in app_id and sandbox. See Launch checklist.

reference-saved-before-start

Major Applies when the app starts workflow runs. Start every run with a new reference, saved on your record before you send the start, and find the record by it rather than by run_id. Any run webhook can arrive before the 201 that starts the run. Revisions count per run, so a reference used for a second run lets the first run’s webhooks be taken for the second’s, and the second’s skipped against the first’s. See Workflow runs and Good to know.

one-run-per-record-at-a-time

Major Applies when the app starts workflow runs. While a record’s last run of an operation hasn’t ended, give back that run (resending its start with the same reference if QuBe Sync never confirmed it) rather than starting another. A second click, a retried job or a page loaded twice would otherwise start a second run with a new reference, which QuBe Sync can’t tell from a new one: the invoice is added twice. See Good to know.

missing-records-in-adds

Major Applies when the app starts add_transaction (or another workflow that takes adds). Send each customer, vendor, employee or item your transaction names that QuickBooks may not have yet as an entry of adds, built from your own record, until it’s linked. add_transaction creates a missing record only from an add your app sent for it. A reference with no entry is matched on its name alone, and when nothing matches, the run can only ask your user to pick an existing record: there’s no choice to create it. See Pick a template.

webhook-url-per-run

Minor Applies when the app starts workflow runs. Give each run a webhook URL that names it, such as /webhooks/qube/runs/<id> (a row written for the run before it starts, which keeps its operation and your record), or route one shared endpoint on event and reference. Each run takes its own webhook_url, so the URL can carry what the job needs: which class handles the run and which record it’s about. One shared URL works too, when its job routes on event first and finds the record by reference. See Handle it in a job and Workflow runs.

idempotency-key

Major Applies when the app queues requests or starts workflow runs. Make one Idempotency-Key for each call you mean to make once, from something new to that call (a UUID, or the run’s new reference), store it with the record before you send it, and resend it only to retry that call. Every call queues a new request: two calls to create an invoice make two invoices, and starting a run twice runs it twice. With the same key, a call sent again after a timeout gets back what the first one queued (Idempotent-Replayed: true), and nothing new is queued. A key lasts until what it created is purged (60 days after a request is answered or a run ends), and then frees up. See Retrying a call safely and Good to know.

Webhooks

The endpoint that takes QuBe Sync’s deliveries, and the job that does the work.

verify-signature

Critical Applies to every integration. Verify every delivery’s X-Qube-Signature before you act on it, and answer 401 when no v1 in it matches. Anyone can POST to a public URL. The header carries a timestamp t and one v1 for each active webhook secret on your app, each the hex HMAC-SHA256 of <t>.<raw body> keyed with that secret. See Verify the signature.

refuse-stale-timestamp

Major Applies to every integration. Refuse a delivery whose t is more than five minutes from now, with 401. It stops someone replaying a captured delivery. Every delivery, retries included, is signed afresh, so a healthy receiver never sees an old one. See Verify the signature.

verify-raw-body

Major Applies to every integration. Verify the signature over the raw bytes you received, before anything parses the body. The signature is over the exact bytes QuBe Sync sent. Parsed and serialized again, JSON has other spacing, key order and escapes, so real deliveries fail, and a parser that runs before the check runs on bodies nobody has verified. See Verify the signature.

answer-fast-work-in-job

Major Applies to every integration. Answer 2xx within 5 seconds, and do the work in a background job. Anything but a 2xx within 5 seconds is a failed delivery: a timeout, a redirect, a 4xx or a 5xx. It’s retried with backoff for about 8 hours, and then marked failed (a run’s latest event can then be sent again with POST .../workflow-runs/{run_id}/redeliver), so work done before the answer can run past it and run again on each retry. See Four jobs for your endpoints and When a delivery fails.

durable-job-queue

Major Applies to every integration. Enqueue each delivery on a durable queue that retries a failed job, and answer 5xx when the enqueue itself fails. Once QuBe Sync has its 2xx, it won’t send that event again: a job that raises, or one an in-process queue loses on a restart, is gone unless your queue keeps and retries it. See Handle it in a job and When a delivery fails.

fixed-operation-map

Critical Applies when the webhook URL names its handler (an operation slug in the path). Turn the operation slug in a webhook URL into its class through a fixed map, and answer 404 for any other slug; never build a class name from the path. The signature covers the body, not the URL, so a signed body can be posted to any path. A class looked up from the path lets the path choose which code runs. See Handle it in a job.

route-on-event

Minor Applies when one endpoint takes more than one kind of webhook (request answers, run events, connection events, the name index’s). Route a webhook on its event first: run, connection and name index webhooks carry one, and a request’s answer doesn’t. One endpoint can take a request’s answers, a run’s events, a connection’s events and name_index.ready. A handler that treats every body as a request’s answer finds no request for the others, and drops them. See Handle it in a job and Name index events.

skip-other-reference

Critical Applies when the app starts workflow runs. Skip, and log, a run event whose reference isn’t the one saved on the record it’s for. The signature covers the body, not the URL, so a record id in the URL is only a pointer, and a record’s earlier runs keep sending until they end. Applying another run’s event writes its results to the wrong record, perhaps another customer’s. See Workflow runs and What your app receives.

run-event-newer-revision

Major Applies when the app starts workflow runs. Apply a run’s webhook only when its revision is higher than the last you applied for that run, and save the revision with the change; notifications are the exception. Deliveries can repeat, cross each other and arrive before the response to the call that caused them. Every body of a run (a webhook, the 201, a decision’s response, a GET) carries its revision, which goes up with every change. A notification is data, not the run’s state, so it’s deduplicated on delivery_id and never skipped by revision. See Workflow runs and What your app receives.

notification-once

Major Applies when a workflow the app starts sends it data (pull_changes, stream_query, or any chart with a notify state). Take each workflow_run.notification once, by its delivery_id, in a store every worker shares. Delivery is at least once: a failed delivery is retried up to 10 times over about an hour, with the same delivery_id. A record of handled ids in one process’s memory misses a retry that reaches another worker. See Sending your app data and What your app receives.

dedupe-request-webhooks

Major Applies when the app queues QuickBooks requests itself, not only through workflows. Deduplicate a request’s webhooks on page_request_id plus state: a retryable webhook isn’t final. A retryable webhook and the answer that follows it share id and page_request_id, and only state differs. Keyed on an id alone, the answer looks like a repeat and is lost. See What to do with each state and Handling far edge cases.

pages-complete-1-to-n

Major Applies when the app queues paged list queries itself. Count a paged query as done once you’ve handled every page from 1 to the done page’s page, not when the done page arrives. Pages can arrive out of order (page 4 before page 3), and a retried page twice. The done page (max_sandbox_iterations_reached in a sandbox) is the last sent, and its page says how many there are. If the session ends partway, the next page ends error, and the query has to be queued again from page 1. See Handling far edge cases and If the session ends partway.

fetch-omitted-response-xml

Minor Applies when the app reads response_xml from request webhooks. When a request’s webhook has response_xml_omitted: true, GET its links.self with your API key for response_xml. A webhook that would be over 1 MB comes without response_xml: it’s null, and links.self is the request’s URL in the API, response_xml included. response_json is always there, so an app that reads only response_json needs nothing here. This one GET, in the webhook’s job, isn’t polling: the answer is already in. See Payload fields.

Questions and endings

What a workflow run asks your app, and every way it ends.

never-pick-an-answer

Major Applies when a workflow the app starts can ask a question (workflow_run.awaiting_input). Show each question a run asks to your user, from its message, details and choices, and answer with the choice they pick; never pick one in code. A run asks only when it can’t decide: which of several records a name means, or whether a lost add went through. A guessed answer can put an invoice on the wrong customer or add it twice, and the run waits for an answer for as long as it takes, so there’s nothing to gain by guessing. A rule your user set up ahead of time is still their choice. See Answering a question, Two kinds of question and What your app receives. Major Applies when a workflow the app starts can ask a question (workflow_run.awaiting_input). Answer at the saved links.decisions as given, with the picked choice and its fields’ data, and take a 409 or a 400 not_awaiting_input on a retry as already answered. links.decisions carries the question’s prompt_token, so an answer sent again can’t answer the run’s next question. A choice names one the server offered, so what it fills in (a candidate’s ListID, say) comes from the server, not your app. Without a token, an answer is taken for whatever the run is asking when it arrives. See Answering a question, Good to know and The API at a glance.

questions-passed-through

Major Applies when a workflow the app starts can ask a question (workflow_run.awaiting_input). Show any question the same way, from its message, details and choices, and post the chosen choice; never branch on a question’s id, and never send a ListID or TxnID your user typed or your app picked. Each prompt carries what a screen needs: what it’s about in details, and every choice with the fields it takes, so one screen answers every question, including ones a workflow adds later. Code per question id falls behind the chart, and it’s where guesses creep in. A candidate’s choice carries its id from the server, and a record field’s value comes from links.search, so every id the run uses is one QuickBooks has. See Answering a question and What your app receives.

question-route-authorized

Critical Applies when the app answers or searches a run’s questions from routes of its own. Check that the signed-in user may act on the record before an answer or search route reads its question; never trust a record id from a form or URL alone. The route answers with your API key, which reaches every connection of your app, and the record id in its URL is the user’s to change. Without the check, any signed-in user can answer another customer’s question, or search their company file, by changing one number. See Answering a question and Do it from your code.

handle-every-ending

Major Applies when the app starts workflow runs. Handle every way a run can end: each outcome of workflow_run.completed, workflow_run.failed and workflow_run.cancelled, with a branch for an outcome you don’t know. A run ends once: completed with an outcome (a chart’s own cancelled outcome is a completed run), failed with an outcome and an error in words, or cancelled, with an optional cancel_reason. What it changed in QuickBooks stays changed either way, and a record left sending after a failure tells your user nothing. See How a run ends.

Stored QuickBooks data

The QuickBooks ids and records your app keeps, and how it finds them again.

ids-keyed-by-connection

Critical Applies when the app stores QuickBooks ids (list_id, txn_id) or QuickBooks records. Key every QuickBooks id you store by the QuBe Sync connection it came from (run events carry it as connection_id) and its list or transaction type: never by the id alone, and never by your record that keeps the connection (the customer’s account or shop). QuickBooks makes a list_id unique only within one list family of one company file (customers, vendors, employees and other names share one; every item type shares another), and a txn_id within one transaction type: two of your customers’ files can hold the same one, and a copy of a file (a restored backup, a test copy) holds every one the same. A customer who connects another company file gets a new connection, so nothing from the old file may match it. See Create and Sending your app data.

stored-id-on-its-own-connection

Critical Applies when the app sends stored QuickBooks ids back (a list_id in a reference such as customer_ref, or a txn_id). Send a stored QuickBooks id only on the connection it came from; on any other, send the name, or find the record again. On another connection the same id is another company file’s record, or none: an invoice can land on the wrong customer, or be refused with 3140. After a customer connects another company file, their old rows’ ids mean nothing there. See Create.

upsert-keeps-later-copy

Major Applies when the app imports or syncs QuickBooks records. Upsert each record you import, keeping the copy with the later time_modified, and don’t let a page saved later bring back a record that was deleted. Pages can be saved in any order: by a queue with several workers, after a retried delivery, or when a query queued again after a session ended partway starts from page 1. pull_changes sends its deletions once the last page is answered, which can be before every page is saved. See If the session ends partway and Sending your app data.

refer-by-id-not-name

Major Applies when the app keeps references to QuickBooks records (customers, items, QuickBooks accounts). Once you know a record’s list_id or txn_id, store it on your own record and refer to the QuickBooks record by it, not by its name. A list_id is stable for the life of the company file; a full_name changes when someone renames the record in QuickBooks. Match a name again only when the id stops working: the record was deleted, or merged into another, and a request that references it is refused with 3120. See Create.

store-what-the-ending-carries

Major Applies when the app keeps what its workflow runs make or find. Store what a run’s final webhook carries in output: the records and ids it made or found, such as add_transaction’s output.record and each entry of its output.references. A run’s output is built for your app. add_transaction’s holds the whole transaction with its lines, and each reference keyed by where it sits in your params (customer_ref, invoice_line_add.0.item_ref), with the record it was found, chosen or created as. summary is the run’s own log of its steps. See How a run ends.

prune-ended-runs-only

Major Applies when the app deletes its run records. Prune only runs that have ended, at least a day after they ended, never the QuickBooks ids you keep, and never the run your next sync’s since comes from unless you keep that time elsewhere. A run deleted before it ends loses its reference, so the next start makes a new one and can add the record twice. A webhook for a deleted run is answered 404, which QuBe Sync retries for hours and then reports. The ids are what say a record is in QuickBooks, and a lost since makes the next sync a full one. See When a delivery fails.

QuickBooks behaviour

What QuickBooks Desktop and the Web Connector do that other APIs don’t, for an app that sends requests itself.

timed-out-look-first

Major Applies when the app queues QuickBooks writes itself (a workflow settles its own). Treat a timed_out request, or an error with error_type outcome_unknown or response_too_large, as perhaps applied: queue a query again (a too-large one paged or narrowed), but look before you send a create or an update again. timed_out means QuickBooks received the request but its answer never came back: the customer’s computer lost its connection, or QuickBooks or the Web Connector was closed, while the request was being processed. An Idempotency-Key doesn’t help here, since the request was queued and sent: sending it again with its key only returns it. outcome_unknown is the same for a write the Web Connector reported an error on mid-request: QuickBooks may have applied it. response_too_large means QuickBooks ran the request and answered with more than one answer may hold, so the answer wasn’t kept: a write happened, and a query needs smaller pages or a narrower filter, not the same request again. See When the answer is lost, When the outcome is unknown, When the answer is too large and Lost answers.

retryable-not-resent

Major Applies when the app queues QuickBooks requests itself. Don’t resend a retryable request: show your customer error.user_message, and wait for the answer. The Web Connector couldn’t get it into QuickBooks (QuickBooks wasn’t running, a dialog was open, or the wrong company file was), so nothing was applied, and QuBe Sync keeps trying on the Web Connector’s next runs, for as long as it takes. A copy sent beside it runs too, once QuickBooks is back. See When QuickBooks is out of reach and What to do with each state.

stale-edit-sequence-reapply

Major Applies when the app sends updates itself. On a stale_edit_sequence (3200), re-apply your change on the record in response_json.error.current and resend with its edit_sequence, deciding first which value wins for a field someone else changed too. QuickBooks refuses an update whose edit_sequence is out of date and changes nothing, but answers with the record as it stands. Resending your fields with the new edit_sequence without a look quietly overwrites what the other person changed. See When the record changed after you read it and Conflicts.

names-unique-across-lists

Major Applies when the app creates customers, vendors, employees, other names or items itself. Plan for a name another list already holds: names are unique across QuickBooks’ customer, vendor, employee and other-name lists together. Creating a customer whose name a vendor already has fails with error 3100 (name_in_use), and items share one name space across every type. A sync that looks only among customers before adding one meets this on real company files. See Create and QuickBooks said no.

ref-number-not-unique

Major Applies when the app stores or matches QuickBooks transactions. Key transactions by txn_id, not by their number: QuickBooks doesn’t keep ref_number unique. QuickBooks accepts a new invoice with a number another invoice already has, and a voided invoice keeps its number, with its amounts at zero. An import keyed on the number merges different invoices. See Tips.

modified-date-filters

Minor Applies when the app syncs changes with modified-date queries itself. Filter lists with from_modified_date and transactions with modified_date_range_filter[from_modified_date], from the newest time_modified you received, sent back exactly as it came. A list query takes the filter directly, and a transaction query takes it nested. QuickBooks labels local times with the standard offset even in daylight saving time and reads the filter the same way, so its own value finds what it should, while a UTC time from your own clock reaches up to an hour further back. A modified-date filter never finds deletions. See Tips and Query.

max-returned-is-not-the-whole-list

Major Applies when the app queries QuickBooks lists itself. Don’t take the answer to a query sent with max_returned alone as the whole list: leave the paging parameters out, or send iterator=true with it, when you need every record. A query sent with max_returned alone gets at most that many records, in one answer, and QuickBooks doesn’t say whether more matched. What the app concludes about the records it didn’t get, such as that they’re gone, is then wrong on a large company file. See Auto-Pagination.

warning-is-success

Major Applies when the app queues QuickBooks writes itself. Treat a response_received that carries a warning as saved: store the record’s id and don’t resend it. A warning is still a success, and data is what QuickBooks saved; sent again, it’s a second record. 530 means a field you sent isn’t supported by this QuickBooks, so the record was saved without it: message names the field. See QuickBooks said yes, with a warning.

branch-on-codes

Minor Applies when the app queues QuickBooks requests itself. Branch on error.error_type and then the status code (error.error_code, or response_json.error.code), never on the message text, and show your customer error.user_message. The codes are stable things to branch on. message is QuickBooks’ or the Web Connector’s raw text, and user_message is plain language for your end user: what went wrong and what to do. See QuickBooks said no and Where each key lives.

Going live

Production: answers by webhook, secrets kept secret, and the production app ready.

webhooks-not-polling

Major Applies to every integration. Take answers from webhooks, not by polling: links.self, and a GET of a request or a run, are for scripts and for looking into a problem. A request is answered only when the customer’s Web Connector checks in, every 5 minutes on a standard connection, so a poll mostly finds nothing. The webhook arrives the moment QuickBooks answers, and a run’s notifications go only to its webhook_url. See Handling the response and Where to next.

no-secrets-in-logs-or-browser

Critical Applies to every integration. Keep the API key and the webhook secret out of logs, error reports, the browser and the repo. Either is enough to act as your app: the key reads and writes every connected company file, and the secret signs webhooks your endpoint will trust. Logged request headers and committed environment files are where they leak. See Do it from your code and Operating it.

production-credentials

Major Applies to every integration. Give production the production app’s own API key and webhook secret, and rotate either by adding the new one before expiring the old. Deliveries are signed with every active secret on the app, one v1 each, so a rotation drops nothing when your endpoint accepts any v1. Expiring an API key takes effect immediately, so deploy its successor first. See Launch checklist and Rotate your webhook secret.

production-runs-published

Major Applies when the app starts workflow runs. Publish every workflow your app starts in the production app before launch: production connections run only published versions. Sandbox and simulated connections run your latest edits, but a production connection always uses the workflow’s published version. Promote each workflow from your sandbox app, which publishes it there too, and fix any company-file values the promotion warns about. See Launch checklist and From sandbox to production.

public-webhook-url

Major Applies to every integration. In production, make every webhook_url your app’s public https address: never localhost, a private address, a tunnel or a qube listen URL, and never one that redirects. QuBe Sync doesn’t follow redirects, and a delivery to an address it can’t reach, such as localhost or a private IP address, isn’t sent or retried: it fails at once. A tunnel or relay URL from development stops working when the laptop does. See When a delivery fails and Receiving webhooks in development.