# Idempotency and safe retries
Source: https://www.desktopaccountingapi.com/docs/guides/idempotency/

> Use Idempotency-Key so a retried write never runs twice, understand write outcomes, and handle a write whose result is unknown.

Networks drop. When a create times out, you cannot tell whether QuickBooks recorded the invoice. Retrying blindly creates duplicates; giving up loses data. This API gives every write an explicit outcome and lets you retry with an idempotency key, so that a retry is never applied twice.

## Idempotency keys

Send an `Idempotency-Key` header on any `POST` or `DELETE`:

```sh
curl https://api.desktopaccountingapi.com/v1/quickbooks-desktop/invoices \
  -H "Authorization: Bearer $DAAPI_SECRET_KEY" \
  -H "Daapi-End-User-Id: eu_01j9..." \
  -H "Idempotency-Key: 3f2c8a10-5b7e-4d21-9c64-8e1f0a2b7d55" \
  -H "Content-Type: application/json" \
  -d '{"customerId":"80000012-1730312001","lines":[{"itemId":"80000003-1730311000","quantity":2,"rate":"125.00"}]}'
```

- Use a new random value, such as a UUID, for each logical operation, and reuse the same value for every retry of that operation.
- 1 to 255 printable ASCII characters. Anything else returns `400 IDEMPOTENCY_KEY_INVALID`.
- Keys are scoped to the project and end user (or to the project for platform calls such as creating an end user) and kept for 7 days.
- Reusing a key with a different request (method, path, end user or body) returns `422 IDEMPOTENCY_KEY_REUSED`. A key always means one request.

The SDKs generate a key for every write and reuse it across their automatic retries. Pass your own key when the retry happens outside the SDK, for example in a job queue that may run the same job twice:

**TypeScript**

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

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

// Derive the key from your own record so a re-run of the job reuses it.
const invoice = await qb.qbd.invoices.create(
  { customerId: "80000012-1730312001", refNumber: "1042" },
  { idempotencyKey: "order-5541-invoice" },
);
console.log(invoice.id);
```

**Python**

```python
from desktopaccountingapi import DesktopAccountingApi

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

# Derive the key from your own record so a re-run of the job reuses it.
invoice = qb.qbd.invoices.create(
    customer_id="80000012-1730312001",
    ref_number="1042",
    idempotency_key="order-5541-invoice",
)
print(invoice.id)
```

**C#**

```csharp
var invoice = await qb.Qbd.Invoices.CreateAsync(
    new InvoiceCreateInput { CustomerId = "80000012-1730312001", RefNumber = "1042" },
    new RequestOptions { IdempotencyKey = "order-5541-invoice" });
```

**Java**

```java
Invoice invoice = qb.qbd().invoices().create(
    new InvoiceCreateInput("80000012-1730312001").refNumber("1042"),
    RequestOptions.create().idempotencyKey("order-5541-invoice"));
```

## What a repeated key returns

| State of the first request | The repeat |
| --- | --- |
| Rejected by validation (a `4xx` before queueing) | Not stored. The repeat is processed as new. |
| Queued, or sent and waiting for QuickBooks | Attaches to the same request and waits for its result (sync), or returns `202` with the same request (async). It never adds a second queue entry. |
| Finished: succeeded, or failed after QuickBooks answered | The stored status and body, with `Daapi-Idempotent-Replayed: true`. |
| Ended before it was sent (canceled, expired, timed out in the queue, connection offline) | The key is released and the repeat runs as a new request, linked by `previousRequestId`. This is safe because nothing reached QuickBooks. |
| `outcome_unknown` | The same `QBD_WRITE_OUTCOME_UNKNOWN` error. The write is never sent again. |

A repeat that arrives while the original is still queued, with a shorter `Daapi-Timeout-Seconds`, never cancels the original. It gets its own `504 QBD_REQUEST_TIMEOUT` and the original keeps running.

## Write outcomes

Every write response and every write error states an `outcome`:

| `outcome` | Meaning | Safe to retry? |
| --- | --- | --- |
| `applied` | QuickBooks confirmed the change. | No need. |
| `not_applied` | The change certainly did not happen: validation failed, it was never sent, or QuickBooks returned an error. | Yes, after fixing the cause if `retryable` is `false`. |
| `pending` | Sent to QuickBooks, result not known yet. | No. Read the request with `GET /v1/requests/{id}?waitSeconds=60`. |
| `unknown` | Sent, and the result could not be confirmed. | No. See below. |
| `not_applicable` | A read. | Reads are always safe to retry. |

Once a write reaches QuickBooks, we never send it again on our own.

## Uncertain writes

A write becomes `outcome_unknown` when it reached QuickBooks but no answer came back: the customer's computer lost its network mid-request, the Web Connector was closed, the session broke, or the response could not be read. The API returns `502 QBD_WRITE_OUTCOME_UNKNOWN` (type `OUTCOME_UNKNOWN_ERROR`), the request resource shows `status: "outcome_unknown"`, and the SDKs raise `OutcomeUnknownError` without retrying.

While a write is uncertain, the connection holds later writes back (`waitingReason: "write_recovery_pending"`) so QuickBooks' record of that write stays intact. Reads keep flowing.

Every write carries a message set ID derived from its request ID, recorded on the request (`quickbooks.messageSetId`). The hold on later writes ends when that Web Connector session ends. The uncertain write itself stays `outcome_unknown`: we never guess, and we never send it again.

Check the company file yourself before writing again:

1. **Look it up by `externalId`.** On creates that support it, we set `externalId` to a UUID derived from the request when you do not send one, and return it in the error's `details`. Query the list (for example `GET /v1/quickbooks-desktop/invoices?updatedAfter=...`) and match on `externalId`, or set your own `externalId` on every create so you always know what to look for.
2. **Or look it up by `refNumber` or `name`.** Invoices, bills and checks usually carry your reference number; list objects have unique names.
3. **If you find it, use it.** If you do not, create it again with a new idempotency key.

> **Note:**
> The simplest protection is to set `externalId` yourself on every create, from your own record ID. Then a lookup always tells you whether a create happened.

## Safe retry checklist

- Let the SDK retry. It only retries what is safe and reuses the key.
- For raw HTTP, send an `Idempotency-Key` on every `POST` and `DELETE`, and reuse it on retry.
- Retry only when `retryable` is `true` (`Daapi-Should-Retry: true`), with backoff, honoring `Retry-After`.
- On `pending`, read the request. On `unknown`, verify. Never resend either.
- Keep keys for longer than your longest retry window, but no longer than 7 days; after that the API treats the key as new.
