# Batch requests
Source: https://www.desktopaccountingapi.com/docs/guides/batch/

> Run up to 25 typed QuickBooks Desktop operations in one Web Connector round trip and get one typed result per operation.

Every QuickBooks Desktop request waits for the end user's Web Connector and for QuickBooks to open the company file. When you need several things at once (create a customer, read a few invoices, run a report), send them as one batch: `POST /v1/quickbooks-desktop/batch` runs up to 25 typed operations together, in order, in one round trip, and returns one result per operation.

## Send a batch

Each member names a typed operation in `operationId` and carries exactly what that operation's own endpoint takes: `pathParams` for the path (`{"id": "..."}`), `query` for query parameters (as JSON values: numbers, booleans and arrays keep their types) and `body` for the JSON body.

```sh
curl -X POST https://api.desktopaccountingapi.com/v1/quickbooks-desktop/batch \
  -H "Authorization: Bearer $DAAPI_SECRET_KEY" \
  -H "Daapi-End-User-Id: $DAAPI_END_USER_ID" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "onError": "continueOnError",
    "requests": [
      { "operationId": "qbd.customers.create", "body": { "name": "Acme Supply" } },
      { "operationId": "qbd.invoices.list", "query": { "customerIds": ["80000012-1730312001"], "limit": 20 } },
      { "operationId": "qbd.invoices.retrieve", "pathParams": { "id": "8000001A-1730312001" } }
    ]
  }'
```

The response holds one result per member, in request order:

```json
{
  "objectType": "qbd_batch",
  "onError": "continueOnError",
  "outcome": "applied",
  "results": [
    { "index": 0, "operationId": "qbd.customers.create", "status": "succeeded", "outcome": "applied", "data": { "id": "80000013-1730313000", "objectType": "qbd_customer", "name": "Acme Supply" }, "error": null, "warnings": [] },
    { "index": 1, "operationId": "qbd.invoices.list", "status": "succeeded", "outcome": "not_applicable", "data": { "objectType": "list", "url": "/v1/quickbooks-desktop/invoices", "data": [], "limitReached": false }, "error": null, "warnings": [] },
    { "index": 2, "operationId": "qbd.invoices.retrieve", "status": "failed", "outcome": "not_applicable", "data": null, "error": { "code": "QBD_OBJECT_NOT_FOUND", "param": "id" }, "warnings": [] }
  ]
}
```

(`data` and `error` are shortened here.) Each `data` is exactly what the operation returns on its own, and each `error` is the error it would return on its own.

The SDKs type every result by its operation. In TypeScript each result is typed by the request at the same position; in Python, C# and Java each result is an instance of the result class of its operation.

**TypeScript**

```ts
import { DesktopAccountingApi } from "@desktopaccountingapi/quickbooks-desktop";

const qb = new DesktopAccountingApi().forEndUser("eu_01j9...");

const batch = await qb.qbd.batch({
  onError: "continueOnError",
  requests: [
    { operationId: "qbd.customers.create", body: { name: "Acme Supply" } },
    { operationId: "qbd.invoices.list", query: { customerIds: ["80000012-1730312001"], limit: 20 } },
    { operationId: "qbd.invoices.retrieve", pathParams: { id: "8000001A-1730312001" } },
  ],
});

// Each result is typed by the request at the same position.
const [customer, invoices, invoice] = batch.results;
if (customer.status === "succeeded") console.log("created", customer.data?.id);
if (invoices.data) console.log(invoices.data.data.length, "invoices", invoices.data.limitReached ? "(more may match)" : "");
if (invoice.status !== "succeeded") console.log(invoice.error?.code);
```

**Python**

```python
from desktopaccountingapi import DesktopAccountingApi
from desktopaccountingapi.types import (
    BatchCustomerCreateInput,
    BatchCustomerCreateResult,
    BatchInvoiceListInput,
    BatchInvoiceListInputQuery,
    BatchInvoiceListResult,
    BatchInvoiceRetrieveInput,
    BatchPathIdInput,
    CustomerCreateInput,
)

qb = DesktopAccountingApi().for_end_user("eu_01j9...")

batch = qb.qbd.batch(
    on_error="continueOnError",
    requests=[
        BatchCustomerCreateInput(body=CustomerCreateInput(name="Acme Supply")),
        BatchInvoiceListInput(query=BatchInvoiceListInputQuery(customer_ids=["80000012-1730312001"], limit=20)),
        BatchInvoiceRetrieveInput(path_params=BatchPathIdInput(id="8000001A-1730312001")),
    ],
)

# Each result is an instance of its operation's result class.
for result in batch.results:
    if isinstance(result, BatchCustomerCreateResult) and result.data is not None:
        print("created", result.data.id)
    elif isinstance(result, BatchInvoiceListResult) and result.data is not None:
        print(len(result.data.data), "invoices", "(more may match)" if result.data.limit_reached else "")
    else:
        print(result.index, result.operation_id, result.status, result.error.code if result.error else None)
```

**C#**

```csharp
var batch = await qb.Qbd.BatchAsync(new BatchInput
{
    OnError = BatchInputOnError.ContinueOnError,
    Requests =
    [
        new BatchCustomerCreateInput { Body = new CustomerCreateInput { Name = "Acme Supply" } },
        new BatchInvoiceListInput { Query = new BatchInvoiceListInputQuery { CustomerIds = ["80000012-1730312001"], Limit = 20 } },
        new BatchInvoiceRetrieveInput { PathParams = new BatchPathIdInput { Id = "8000001A-1730312001" } },
    ],
});

// Each result is an instance of its operation's result class.
foreach (var result in batch.Results)
{
    switch (result)
    {
        case BatchCustomerCreateResult { Data: { } customer }:
            Console.WriteLine($"created {customer.Id}");
            break;
        case BatchInvoiceListResult { Data: { } invoices }:
            Console.WriteLine($"{invoices.Data.Count} invoices{(invoices.LimitReached ? " (more may match)" : "")}");
            break;
        default:
            Console.WriteLine($"{result.Index} {result.OperationId}: {result.Status} {result.Error?.Code}");
            break;
    }
}
```

**Java**

```java
Batch batch = qb.qbd().batch(new BatchInput()
        .onError("continueOnError")
        .requests(List.of(
                new BatchCustomerCreateInput().body(new CustomerCreateInput("Acme Supply")),
                new BatchInvoiceListInput().query(new BatchInvoiceListInputQuery().customerIds(List.of("80000012-1730312001")).limit(20)),
                new BatchInvoiceRetrieveInput(new BatchPathIdInput("8000001A-1730312001")))));

// Each result is an instance of its operation's result class.
for (BatchResult result : batch.results()) {
    if (result instanceof BatchCustomerCreateResult && ((BatchCustomerCreateResult) result).data() != null) {
        System.out.println("created " + ((BatchCustomerCreateResult) result).data().id());
    } else if (result instanceof BatchInvoiceListResult && ((BatchInvoiceListResult) result).data() != null) {
        BatchInvoiceListPage invoices = ((BatchInvoiceListResult) result).data();
        System.out.println(invoices.data().size() + " invoices" + (invoices.limitReached() ? " (more may match)" : ""));
    } else {
        System.out.println(result.index() + " " + result.operationId() + ": " + result.status());
    }
}
```

## Members

- **Any typed operation.** Every operation under `/v1/quickbooks-desktop/` can be a member: lists, retrieves, creates, updates, deletes, voids, merges, custom fields and reports. The health check and passthrough cannot: send passthrough qbXML as one message set instead.
- **Validated like the endpoint.** Each member is validated by the same schema and rules as its own endpoint before anything is queued. One invalid member fails the whole call with `400`, and `param` names the member and field, for example `requests[2].body.name` or `requests[0].query.limit`. Nothing is sent.
- **Lists run as one query.** A batch never starts a cursor: a cursor list runs as one query of at most `limit` records (default 150, at most 150), and its `data` is `{ objectType, url, data, limitReached }`. `limitReached` is `true` when QuickBooks returned exactly `limit` records, so more may match: read the rest with the list endpoint and its cursor, or narrow the filters. `cursor` is refused, and `ids`, `fullNames` and `refNumbers` return every match, as they do on the list endpoint.
- **Updates that omit line collections.** An update that sends some line arrays of a transaction and omits others first reads the transaction to keep the omitted lines, exactly as the update endpoint does ([Updates and line items](https://www.desktopaccountingapi.com/docs/quickbooks/updates-and-line-items/)). That read is a separate request in the request log and runs before the batch is queued, so it needs the connection online.
- **At most 25 members.** Each member's response comes back in the same response, so keep large lists and reports to a few per batch.

## Results and errors

`results[i]` answers `requests[i]`:

| Field | Meaning |
| --- | --- |
| `index` | Position of the member in `requests` |
| `operationId` | The member's operation |
| `status` | `succeeded`, `failed`, `not_processed` or `unknown` |
| `outcome` | `applied` or `not_applied` for a write member, `not_applicable` for a read, `unknown` when the response was lost |
| `data` | The operation's typed result when `status` is `succeeded`, otherwise `null` |
| `error` | The member's error (code, message, fixes, `param`), otherwise `null` |
| `warnings` | QuickBooks warnings and values read leniently for this member |

`onError` decides what happens after a member fails:

- `stopOnError` (default): QuickBooks skips every member after the first failure. Each skipped member has `status: "not_processed"` and the error `QBD_REQUEST_NOT_PROCESSED` (QuickBooks status 3231); nothing in it was applied. Use it when later members depend on earlier ones.
- `continueOnError`: every member runs, whatever failed before it. Use it for independent work.

When QuickBooks answered the batch, the call returns `200` with every result, even if every member failed (the request in the request log is then `failed` with the first member's error). Errors about the batch itself (validation, the connection, billing, timeouts) are returned as the call's error, as for any request ([Error handling](https://www.desktopaccountingapi.com/docs/guides/error-handling/)).

## Writes and safe retries

A batch with at least one write member is a write: send one `Idempotency-Key` for the whole batch (the SDKs do this for you). A retry with the same key returns the stored results and never runs the batch twice; the same key with a different batch (other members, another order or `onError`) is refused with `422 IDEMPOTENCY_KEY_REUSED`. QuickBooks receives all members as one message set with one message-set ID.

If the response from QuickBooks is lost (the Web Connector or the network failed while QuickBooks was working), it is unknown which members were applied. The call fails with `502 QBD_WRITE_OUTCOME_UNKNOWN`, and `error.details.members` lists every member:

```json
{ "index": 0, "operationId": "qbd.customers.create", "status": "unknown", "outcome": "unknown", "externalId": "0b9a0a54-2d93-4e2b-9f0a-3c3f2e1d4a10" }
{ "index": 1, "operationId": "qbd.invoices.retrieve", "status": "failed", "outcome": "not_applicable" }
```

- Every write member is `unknown`. Never resend the batch with a new key. Check each write member in QuickBooks first: a create by its `externalId` (every create carries one), an update, delete or void by the object's name or reference number ([Idempotency and safe retries](https://www.desktopaccountingapi.com/docs/guides/idempotency/)).
- A read member is `failed`. A read in the same batch never proves that a write was applied, even when it ran.
- Later writes on the connection wait until QuickBooks has finished with the batch, as for any write with an unknown outcome.
- `GET /v1/requests/{id}` shows the same `details.members`. When the API later finds a create in QuickBooks by its `externalId`, that member shows `status: "succeeded"` with the record's QuickBooks ID in `objectId`, while the others stay `unknown`.
- Once every write member is found, the request becomes `succeeded` with `recovered: true`, and its `result` is the batch response: each create member is `succeeded` with `data: null` and the ID in `recoveredObjectId`. Retrieve the record by that ID. A replay with the same `Idempotency-Key` returns the same results and sends nothing to QuickBooks again; a read member in such a batch is `failed` (`QBD_READ_INTERRUPTED`), so send it again on its own.

## Async mode, read-only keys and limits

- **Async mode.** With `Prefer: respond-async` the call returns `202` with the request resource; its `result` is the batch response once QuickBooks has answered ([Request lifecycle](https://www.desktopaccountingapi.com/docs/guides/request-lifecycle/)).
- **Read-only keys** may batch reads only. A batch with a write member is refused with `403 API_KEY_READ_ONLY`, and `param` names the first write member.
- **Timeouts** apply to the whole batch: `Daapi-Timeout-Seconds` is how long the call waits for QuickBooks to answer every member.
- **Rate limits.** A batch is one request for the rate limits and one entry in the connection's queue.

## Billing

A batch is billed like the same requests sent one by one. A production company file becomes active for the day when at least one member succeeds; a batch whose members all fail adds nothing. Health checks are not batch members, sample company files and test projects stay free, and a billing block refuses a production batch before anything is queued, as for any data request ([Pricing and billing](https://www.desktopaccountingapi.com/docs/platform/billing/)).

## With AI agents

The MCP server runs batches through `invoke_api_endpoint` with the endpoint `qbd.batch`. A read-only MCP connection may batch reads only ([MCP server](https://www.desktopaccountingapi.com/docs/guides/mcp/)).
