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

# Making requests

> Query, create, update and delete anything in QuickBooks Desktop with plain JSON. Every operation follows the same pattern.

See the [API Reference](https://docs.qubesync.com/api-reference.md) for full documentation of all 261 QuickBooks operations and their REST(ish) endpoints, and even try them from right here in the docs. Better yet, use the [MCP server](https://docs.qubesync.com/mcp-server.md) to let your agent build and validate any request.

Every QuickBooks operation is a REST endpoint under a connection, covering lists
(customers, vendors, items, accounts), transactions (invoices, bills, payments, sales receipts, journal
entries), payroll and reports. They are all compiled and validated directly from QuickBooks' own schema definitions, so they support
every feature native qbXML offers.

**1. Send JSON**

You send JSON to `https://qubesync.com/api/v2/connections/{connection_id}/{resource}`.

**2. Save the queued request's ID**

Your request is validated against QuickBooks' schema. A valid request is queued, and you get `201 Created` with the queued request.

**3. Receive the answer**

When the Web Connector next checks in, QuickBooks runs it and the answer is POSTed to your
`webhook_url` as JSON.

## The pattern

| You want to | Method | Example | QuickBooks request |
| - | - | - | - |
| List or search | `GET` | `GET /connections/{id}/customers?name_starts_with=River` | `CustomerQueryRq` |
| Create | `POST` | `POST /connections/{id}/invoices` | `InvoiceAddRq` |
| Update | `PUT` | `PUT /connections/{id}/customers` | `CustomerModRq` |
| Delete | `DELETE` | `DELETE /connections/{id}/lists` | `ListDelRq` |
| Do something else | `POST` | `POST /connections/{id}/txn-void` | `TxnVoidRq` |

Fields are snake\_case versions of QuickBooks' own names (`ListID` becomes `list_id`, `EditSequence` becomes
`edit_sequence`), in the request body and in the answer.

Add `webhook_url` as a **query parameter** to every call, whatever the method. That's where the answer
goes.

## Use a library

QuBe Sync's libraries make these calls for you: the API key as basic auth, the response's `data` out, an
`Idempotency-Key` on every call that queues (yours, or one they make and resend on their own retries), safe
retries after a timeout or a `503`, and one `APIError` with the API's `status`, `code` and message. A call and
its retries stop after 60 seconds, and a retried `DELETE` answered `404` after a lost answer counts as done.
They take the key as an argument, so your app reads it from the environment. They need Ruby 3.3, Python 3.8 or
Node 16.17 ([the webhook libraries' table](https://docs.qubesync.com/webhooks.md#verify-the-signature) has the rest).

```ruby Ruby theme={null}
# gem "qube_sync", ">= 0.4.0", require: ["qube_sync/webhooks", "qube_sync/client"]
qube = QubeSync::Client.new(api_key: ENV.fetch("QUBE_API_KEY"))

request = qube.customer.create(
  connection_id, { name: "Riverbend Electric" },
  webhook_url: "https://your-app.example/webhooks/qube",
  idempotency_key: customer.qube_idempotency_key
)

qube.customer.query(connection_id, { name_starts_with: "River" },
                    webhook_url: "https://your-app.example/webhooks/qube")
```

```javascript Node.js theme={null}
// npm install qubesync@^0.2.0
const { Client } = require("qubesync");
const qube = new Client({ apiKey: process.env.QUBE_API_KEY });

const request = await qube.customer.create(
  connectionId,
  { name: "Riverbend Electric" },
  {
    webhookUrl: "https://your-app.example/webhooks/qube",
    idempotencyKey: customer.qubeIdempotencyKey,
  },
);

await qube.customer.query(connectionId, { name_starts_with: "River" }, {
  webhookUrl: "https://your-app.example/webhooks/qube",
});
```

```python Python theme={null}
# pip install "qubesync>=0.3.0"
import os
from qubesync import Client

qube = Client(os.environ["QUBE_API_KEY"])

request = qube.customer.create(
    connection_id,
    {"name": "Riverbend Electric"},
    webhook_url="https://your-app.example/webhooks/qube",
    idempotency_key=customer.qube_idempotency_key,
)

qube.customer.query(
    connection_id,
    {"name_starts_with": "River"},
    webhook_url="https://your-app.example/webhooks/qube",
)
```

Every operation in the [API reference](https://docs.qubesync.com/api-reference/introduction.md) is a method, named as workflows name it:
the record's kind, then the action (`customer.create`, `invoice.query`, `txn_void.execute`). Each one's params
are typed, with every field described: Ruby has RBS signatures, Python `TypedDict`s and Node `.d.ts` types. A
kind's code loads the first time your app uses it. The same client creates connections, starts workflow runs,
answers their questions and reads your queued requests, a page at a time or all of them. Each library's
README lists every method.

## A small API client

Without a library, every example here uses the same few lines: JSON in, the response's `data` out.

```ruby Ruby theme={null}
require "json"
require "net/http"

module Qube
  # A non-2xx answer: status is the HTTP status, and body what QuBe Sync
  # said (with errors.code).
  class Error < StandardError
    attr_reader :status, :body

    def initialize(status, body)
      @status = status
      @body = body
      super("QuBe Sync answered #{status}: #{body}")
    end
  end

  # JSON in, the response's data out: path is under /api/v2
  # ("/connections"), or a whole URL from a webhook's links.
  def self.api(method, path, body = nil, query: {}, headers: {})
    uri = URI(path)
    uri = URI("https://qubesync.com/api/v2#{path}") unless uri.host
    if query.any? # after a link's own query
      extra = URI.encode_www_form(query)
      uri.query = uri.query ? "#{uri.query}&#{extra}" : extra
    end
    req = Net::HTTP.const_get(method.to_s.capitalize).new(uri, headers)
    req.basic_auth(ENV.fetch("QUBE_API_KEY"), "")
    req["Accept"] = "application/json"
    if body
      req["Content-Type"] = "application/json"
      req.body = JSON.generate(body)
    end

    options = { use_ssl: uri.scheme == "https",
                open_timeout: 5, read_timeout: 30 }
    res = Net::HTTP.start(uri.host, uri.port, options) do |http|
      http.request(req)
    end
    unless res.is_a?(Net::HTTPSuccess)
      raise Error.new(res.code.to_i, res.body)
    end

    JSON.parse(res.body)["data"]
  end
end
```

```javascript Node.js theme={null}
/**
 * A non-2xx answer: `status` is the HTTP status, and `body` what QuBe
 * Sync said (with errors.code).
 */
class QubeSyncError extends Error {
  constructor(status, body) {
    super(`QuBe Sync answered ${status}: ${body}`);
    this.status = status;
    this.body = body;
  }
}

// JSON in, the response's data out: `path` is under /api/v2
// ("/connections"), or a whole URL from a webhook's links.
async function qube(method, path, body, options = {}) {
  const { query = {}, headers = {} } = options;
  const url = /^https?:\/\//.test(path)
    ? new URL(path)
    : new URL(`https://qubesync.com/api/v2${path}`);
  for (const [key, value] of Object.entries(query)) {
    url.searchParams.set(key, value);
  }
  const credentials = Buffer.from(`${process.env.QUBE_API_KEY}:`);
  const auth = `Basic ${credentials.toString("base64")}`;
  const hasBody = body !== undefined;
  const res = await fetch(url, {
    method,
    headers: {
      Authorization: auth,
      Accept: "application/json",
      ...(hasBody ? { "Content-Type": "application/json" } : {}),
      ...headers,
    },
    body: hasBody ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(30000),
  });
  const text = await res.text();
  if (!res.ok) throw new QubeSyncError(res.status, text);
  return JSON.parse(text).data;
}
```

```python Python theme={null}
import os
from typing import Any, Dict, Optional

import requests


class QubeSyncError(Exception):
    """A non-2xx answer: status is the HTTP status, and body what
    QuBe Sync said (with errors.code)."""

    def __init__(self, status: int, body: str) -> None:
        super().__init__(f"QuBe Sync answered {status}: {body}")
        self.status = status
        self.body = body


def qube(
    method: str,
    path: str,
    body: Optional[Dict[str, Any]] = None,
    headers: Optional[Dict[str, str]] = None,
    **query: Any,
) -> Any:
    """JSON in, the response's data out: path is under /api/v2
    ("/connections"), or a whole URL from a webhook's links."""
    url = path
    if not path.startswith(("http://", "https://")):
        url = f"https://qubesync.com/api/v2{path}"
    response = requests.request(
        method,
        url,
        params=query or None,
        json=body,
        headers={"Accept": "application/json", **(headers or {})},
        auth=(os.environ["QUBE_API_KEY"], ""),
        timeout=(5, 30),  # seconds: to connect, to read
    )
    if not 200 <= response.status_code < 300:
        raise QubeSyncError(response.status_code, response.text)
    return response.json()["data"]
```

## Query

Filters are query parameters. Each resource has QuickBooks' own filters, such as names, modified dates,
active status and reference numbers:

Request

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

```http theme={null}
GET /api/v2/connections/{connection_id}/customers?active_status=ActiveOnly&name_starts_with=River&max_returned=25&webhook_url=https://your-app.example/webhooks/qube
```

```bash theme={null}
curl -u "$QUBE_API_KEY:" \
  -G \
  "https://qubesync.com/api/v2/connections/$CONN_ID/customers" \
  --data-urlencode "active_status=ActiveOnly" \
  --data-urlencode "name_starts_with=River" \
  --data-urlencode "max_returned=25" \
  --data-urlencode "webhook_url=https://your-app.example/webhooks/qube"
```

A query's answer is always a **list** in `response_json.data`, even when one record or none matches. A list
query QuickBooks pages arrives a page at a time, each page as its own webhook, unless you look records up by
id or name or send `max_returned` alone. See [Pagination](https://docs.qubesync.com/pagination.md).

> [!TIP]
>
> Ask only for what changed, so a full import becomes a cheap incremental sync. The two kinds of query
> take the modified dates differently.
>
> A **list** query (`customers`, `vendors`, `items`...) takes `from_modified_date` and `to_modified_date`
> as plain query parameters.
>
> A **transaction** query (`invoices`, `bills`, `payments`...) takes them inside
> `modified_date_range_filter`.
>
> You can find full set of options in the [API Reference](https://docs.qubesync.com/api-reference.md), or use the [MCP](https://docs.qubesync.com/mcp-server.md) to build and validate requests.

Date fields take `YYYY-MM-DD`. Datetime fields take ISO 8601, such as `2026-09-03T17:28:28`, with an
optional offset such as `+00:00`. A trailing `Z` is accepted and sent to QuickBooks as `+00:00`. A value in
any other shape is refused with `400` when you queue the request.

A filter that takes several values, such as `ref_number`, `list_id` or `txn_id`, is an array. Repeat the key
(`ref_number=1001&ref_number=1002`) or use brackets (`ref_number[]=1001&ref_number[]=1002`); either asks for
every value you list. A comma doesn't separate values: `ref_number=1001,1002` looks for the one number
`1001,1002`. A filter that takes a single value, such as `max_returned`, can't be repeated, and the request
is refused with `400`.

## Create

The body is the record's fields. Refer to associations by `full_name` or `list_id`:

Request

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

```http theme={null}
POST /api/v2/connections/{connection_id}/invoices?webhook_url=https://your-app.example/webhooks/qube

{
  "customer_ref": {"full_name": "Riverbend Electric"},
  "invoice_line_add": [
    {"item_ref": {"full_name": "Consulting"}, "quantity": 3, "rate": 150},
    {"item_ref": {"full_name": "Widget"}, "quantity": 2}
  ]
}
```

```bash theme={null}
curl -u "$QUBE_API_KEY:" \
  -X POST \
  "https://qubesync.com/api/v2/connections/$CONN_ID/invoices?webhook_url=https://your-app.example/webhooks/qube" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_ref": {"full_name": "Riverbend Electric"},
    "invoice_line_add": [
      {"item_ref": {"full_name": "Consulting"}, "quantity": 3, "rate": 150},
      {"item_ref": {"full_name": "Widget"}, "quantity": 2}
    ]
  }'
```

A create's answer is the **new record** in `response_json.data`, with the ids QuickBooks gave it:

```json response_json (abridged) theme={null}
{
  "status": "success",
  "data": {
    "txn_id": "1-1790470623",
    "ref_number": "1001",
    "txn_date": "2026-09-27",
    "due_date": "2026-10-27",
    "customer_ref": { "list_id": "8000001F-1790470623", "full_name": "Riverbend Electric" },
    "subtotal": 530.0,
    "balance_remaining": 530.0,
    "is_paid": false,
    "edit_sequence": "1790470623",
    "invoice_line_ret": [
      { "txn_line_id": "1L-1790470623", "item_ref": { "full_name": "Consulting" }, "desc": "Consulting services (hourly)", "quantity": 3.0, "rate": 150.0, "amount": 450.0 },
      { "txn_line_id": "2L-1790470623", "item_ref": { "full_name": "Widget" }, "desc": "Standard widget", "quantity": 2.0, "rate": 40.0, "amount": 80.0 }
    ]
  }
}
```

Store the `list_id` or `txn_id` against your own record. A `list_id` is stable for the life of the company
file; a `full_name` changes when someone renames the record in QuickBooks.

Keep it with the connection it came from. 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. So when your app keeps its
own table of QuickBooks records, find each row by the connection, the kind and the id together: never by the
id alone, and not by your customer's account either, since a customer who connects another company file gets a
new connection.

If QuickBooks saved the record but left something out, such as a field this QuickBooks doesn't support, the
answer is still a success, with `response_json.warning` saying what. See
[Warnings](https://docs.qubesync.com/error_handling.md#quickbooks-said-yes-with-a-warning).

> [!WARNING]
>
> 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`). Plan for it: the
> [`ensure`](https://docs.qubesync.com/workflows#pick-a-template) workflow handles it for you.

## Update

QuickBooks uses optimistic locking. Send the record's `list_id` (or `txn_id`) with the `edit_sequence` you
last read, and preferably only the fields you're changing:

Request

<pre>`PUT 
          /api/v2`</pre>

```http theme={null}
PUT /api/v2/connections/{connection_id}/customers?webhook_url=https://your-app.example/webhooks/qube

{
  "list_id": "80000020-1790470623",
  "edit_sequence": "1790470623",
  "phone": "512-555-0100"
}
```

```bash theme={null}
curl -u "$QUBE_API_KEY:" \
  -X PUT \
  "https://qubesync.com/api/v2/connections/$CONN_ID/customers?webhook_url=https://your-app.example/webhooks/qube" \
  -H "Content-Type: application/json" \
  -d '{
    "list_id": "80000020-1790470623",
    "edit_sequence": "1790470623",
    "phone": "512-555-0100"
  }'
```

The answer is the updated record, with a **new** `edit_sequence`. If the record changed in QuickBooks since
you read it, the update is refused with error 3200 (`stale_edit_sequence`): query the record again and
retry with the fresh `edit_sequence`, or let your user decide how to manage conflicts. A [workflow](https://docs.qubesync.com/workflows.md) like `upsert` can handle this for you.

## Requests are pre-validated

QuBe Sync validates every request against QuickBooks' schema before queuing it, so a typo fails in your
test suite, not on your customer's PC. You get `400` and nothing is queued:

```json 400 Bad Request theme={null}
{ "errors": "unknown parameter: \"emial\"" }
```

```json 400 Bad Request theme={null}
{ "errors": "active_status: must be one of [:all, :active_only, :inactive_only], got \"Sometimes\"" }
```

## Ordering, dependencies and retries

| How it works | What to do |
| - | - |
| **Requests run in the order you queued them**, one connection at a time. If a Web Connector session ends with a request taken but not answered, what was queued after it waits until it's answered, put back or [timed out](https://docs.qubesync.com/error_handling.md#when-the-answer-is-lost). | Queue a request that depends on another (an invoice for a customer you're creating) **from the first one's webhook**, not alongside it. Or let a [workflow](https://docs.qubesync.com/workflows.md) run the sequence for you. |
| **Every call queues a new request.** If your app sends the same call twice, QuickBooks runs it twice: two calls to create an invoice make two invoices. QuBe Sync itself never resends a request QuickBooks might have received. It only retries a request the Web Connector couldn't deliver at all. | **Retry with an idempotency key.** Send an `Idempotency-Key` header with each call, and a call you send again after a timeout queues nothing new (see [Retrying a call safely](#retrying-a-call-safely)). |
| **QuBe Sync is already retrying** a request that's `retryable`. | **Don't resend it.** See [Error handling](https://docs.qubesync.com/error_handling.md). |

### Retrying a call safely

A timeout or a dropped connection can leave your app without the `201`, and so without the request's `id`,
though the request was queued. Send an `Idempotency-Key` header with each call, and send a call whose answer
you never got again with the same key:

```bash theme={null}
curl -u "$QUBE_API_KEY:" \
  -X POST "https://qubesync.com/api/v2/connections/$CONN_ID/customers?webhook_url=https://your-app.example/webhooks/qube" \
  -H "Idempotency-Key: 4c1f0b9e-7d2a-4b8e-9f3a-1e6d5c2b7a90" \
  -H "Content-Type: application/json" \
  -d '{"name": "Acme Co"}'
```

**Idempotency-Key** `string`

1 to 255 printable ASCII characters. Every operation, [queueing qbXML](#sending-qbxml-yourself) and
[starting a workflow run](https://docs.qubesync.com/workflows.md) take one. Other calls ignore the header.

A key belongs to your app, so your sandbox and production apps never share one. It lasts until what it
created is purged, 60 days after the request is answered or the run ends (90 after a request fails), and then frees up: a retry
after that queues something new.

| You send | What happens |
| - | - |
| The same key with the same call (method, path, query and body) | `201` with the request the first call queued, as it is now, and the header `Idempotent-Replayed: true`. Nothing new is queued, and no extra webhook goes out. |
| The same key with a different call | `422` naming the header. A key stands for one request: generate a new one (a UUID) for each request you mean to make once, and store it with your record before you send it. |
| A call that's refused | `400`, `404` or `422`. It queues nothing and uses up no key: fix it and send it again with the same key. |

The key covers a lost `201` from QuBe Sync, not a lost answer from QuickBooks. A request that ends
`timed_out` was queued and sent, so sending it again with its key only returns it. See
[When the answer is lost](https://docs.qubesync.com/error_handling.md#when-the-answer-is-lost).

## Sending qbXML yourself

When no operation expresses the request you need, you already have the qbXML (from an older integration,
or from Intuit's own examples), or you want QuickBooks to run several requests together, [queue the qbXML](https://docs.qubesync.com/api-reference/v2/queued-requests/queue-a-qbxml-request.md)
as it is:

Request

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

```http theme={null}
POST /api/v2/connections/{connection_id}/queued-requests?webhook_url=https://your-app.example/webhooks/qube

{
  "request_xml": "<?xml version=\"1.0\" encoding=\"utf-8\"?><?qbxml version=\"16.0\"?><QBXML><QBXMLMsgsRq onError=\"stopOnError\"><CustomerQueryRq requestID=\"1\"><MaxReturned>10</MaxReturned></CustomerQueryRq></QBXMLMsgsRq></QBXML>"
}
```

```bash theme={null}
curl -u "$QUBE_API_KEY:" \
  -X POST \
  "https://qubesync.com/api/v2/connections/$CONN_ID/queued-requests?webhook_url=https://your-app.example/webhooks/qube" \
  -H "Content-Type: application/json" \
  -d '{
    "request_xml": "<?xml version=\"1.0\" encoding=\"utf-8\"?><?qbxml version=\"16.0\"?><QBXML><QBXMLMsgsRq onError=\"stopOnError\"><CustomerQueryRq requestID=\"1\"><MaxReturned>10</MaxReturned></CustomerQueryRq></QBXMLMsgsRq></QBXML>"
  }'
```

**request\_xml** `string` *required*

The whole qbXML document, sent to QuickBooks as it is. It's checked before anything is queued:

1. It has no DOCTYPE. qbXML doesn't use one.
2. It declares its qbXML version, such as `<?qbxml version="16.0"?>`, and the version is 13.0, 14.0,
   15.0 or 16.0.
3. It is valid against that version's qbXML schema.
4. `<QBXML>` holds only `<QBXMLMsgsRq>`, with at least one request inside it.
5. In a batch (two or more requests), no request has an `iterator` attribute. Queue an iterator query
   on its own.
6. `onError` is `stopOnError` or `continueOnError`. QuickBooks Desktop refuses `rollbackOnError` for
   the whole envelope.

If it breaks one, nothing is queued and the answer is `422`, with `errors.request_xml` saying which:

```json 422 Unprocessable Entity theme={null}
{ "errors": { "request_xml": ["CustomerQueryRq has an iterator: queue an iterator query on its own, not in a batch"] } }
```

Write every amount with its two decimals: `<TotalAmount>40.00</TotalAmount>`, not `40`. The schema
allows `40`, so it passes these checks, but QuickBooks refuses it with `3040` when it runs the request.
Quantities and rates can be whole numbers. The operations write amounts this way for you.

**webhook\_url** `string`

Where the answer goes. It stays in the query string, as on every other call.

The answer arrives at your `webhook_url` like any other request. qbXML you send yourself is sent as you wrote
it, so it isn't [paged](https://docs.qubesync.com/pagination.md) for you. To page a single query, give it `iterator="Start"` and a
`MaxReturned` page size, and you get one webhook per page of results.

The `201` carries `warnings` beside `data`, a list that is empty when there's nothing to say. A query
QuickBooks pages that has neither an `iterator` nor `MaxReturned`, and doesn't look records up by id or
name, gets an `unpaged_query` warning: QuickBooks will answer it with every match at once, and one answer
over the [size limit](https://docs.qubesync.com/error_handling.md#when-the-answer-is-too-large) isn't kept. Warnings are only in that
`201`; `links.self` doesn't return them.

### A batch

Put several requests in one `<QBXMLMsgsRq>` and QuickBooks runs them in order, in one go. `response_json`
then lists every request's outcome, in the order you sent them:

```json response_json for a batch (abridged) theme={null}
{
  "status": "error",
  "responses": [
    { "request": "CustomerAddRq", "request_id": "1", "status": "success",
      "data": { "list_id": "80000021-1790470623", "full_name": "Lakeside Dental" } },
    { "request": "ItemServiceAddRq", "request_id": "2", "status": "error",
      "error": { "code": 3100, "type": "name_in_use", "message": "The name \"Consulting\" of the list element is already in use." } },
    { "request": "InvoiceAddRq", "request_id": "3", "status": "not_processed" }
  ]
}
```

Each entry has `request` (the request's name), `request_id` (its `requestID`, or `null`) and `status`. A
request QuickBooks ran or refused is `success` or `error`, with the same `data`, `message`, `warning` or
`error` as a single request's summary. `not_processed` is a request QuickBooks skipped: with
`onError="stopOnError"` it stops at the first request that fails and answers each one after it "The
request has not been processed." (status 3231), so those are `not_processed`. With
`onError="continueOnError"` it carries on and runs each one. A 3231 that doesn't follow an earlier failure
is QuickBooks declining that request itself, because the company file's edition or preferences don't
allow it, so it's an `error`. The top-level `status` is `success` only when every entry is.

A batch QuickBooks answered is `response_received`, whatever its entries say, because some of its requests
may have gone through: in the example above, the customer was added. Look at the entries to see what
happened to each request, and don't resend the batch. Queue again only the requests that need it.

## Handling the response

Create your requests with [webhook urls](https://docs.qubesync.com/webhooks.md) to handle QuickBooks' response!


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