# Retry writes safely with Idempotency-Key

Send an `Idempotency-Key` header on a create or update, and resend the same key with the same body when you retry. Apideck and the connector then apply the write at most once, so a timeout no longer forces a choice between a possible duplicate and a possibly missing record.

- [The problem: writes with an uncertain outcome](#the-problem-writes-with-an-uncertain-outcome)
- [Send a key and retry](#send-a-key-and-retry)
- [Supported connectors and operations](#supported-connectors-and-operations)
- [Connectors without key support](#connectors-without-key-support)
- [Choosing keys](#choosing-keys)

## The problem: writes with an uncertain outcome

A write to an accounting system can fail after the record was saved: Apideck's request budget runs out, the connection drops, or the accounting platform answers `504`. Apideck cannot tell whether the record exists, so the error says so:

```json
{
  "type_name": "ConnectorExecutionError",
  "message": "Quickbooks Connector Returned Error",
  "detail": {
    "context": {
      "connector": "QuickBooks",
      "unified_api": "accounting",
      "resource": "journal-entries",
      "action": "add"
    },
    "retriable": false,
    "outcome": "uncertain"
  }
}
```

`outcome: "uncertain"` means the record may or may not exist. Without a key, both choices are wrong some of the time. Retrying can post a second invoice or journal entry; not retrying can lose a write that never landed. Most ledgers have no unique business key to catch the duplicate either: Xero only enforces invoice numbers on sales invoices, and QuickBooks accepts two journal entries with the same `DocNumber`.

With a key, the same error comes back with `retriable: true`, and the retry is safe.

## Send a key and retry

### Send the key

Add the header to the request. Use the same key and the same body on every retry of that one write.

```http
POST /accounting/journal-entries HTTP/1.1
Host: unify.apideck.com
Authorization: Bearer {API_KEY}
x-apideck-app-id: {APP_ID}
x-apideck-consumer-id: {CONSUMER_ID}
x-apideck-service-id: quickbooks
Idempotency-Key: payroll-run-2026-10-31-entity-eu
Content-Type: application/json

{
  "title": "Payroll October 2026",
  "posted_at": "2026-10-31T00:00:00.000Z",
  "line_items": [
    { "type": "debit", "total_amount": 12500, "ledger_account": { "id": "62" } },
    { "type": "credit", "total_amount": 12500, "ledger_account": { "id": "35" } }
  ]
}
```

The key is 1 to 255 printable ASCII characters, sent bare or as a quoted string. A UUID works, and so does a readable key built from your own record id. Apideck derives a 32-character key from it for the connector, so vendor length limits (QuickBooks allows 50 characters) never apply to your key.

The header follows the IETF [Idempotency-Key HTTP header](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/) draft. The Python (0.48.0), Java (0.47.0), Go (0.45.0), PHP (0.31.0) and C# (0.42.0) SDKs accept the key as an optional parameter on the supported operations, for example `idempotency_key` in Python. The TypeScript SDK adds it in its next release; until then, send the header with a direct HTTP request from Node.

### Responses by situation

| Situation | Response |
| --- | --- |
| First request with a key | Sent to the connector as usual |
| Retry with the same key and body after a timeout or 5xx | The connector's deduplication answers: the original record, never a second one |
| Retry while the first attempt is still running | `409 IdempotencyKeyInProgressError`. Wait and retry with the same key |
| Same key, different body | `422 IdempotencyKeyMismatchError`. Nothing is sent |
| Key store unavailable | `503 IdempotencyStoreUnavailableError`. Nothing is sent; retry with the same key |
| Malformed key | `400 IdempotencyKeyInvalidError` |
| Key on an operation or connector that does not support it | `400 IdempotencyKeyUnsupportedError`. Nothing is sent |
| Key on a read or delete | Ignored |
| No key | Unchanged behaviour |

Apideck does not store responses. It makes sure only one attempt per key reaches the connector at a time, and the connector's own deduplication decides the result. That is why an unsupported operation is refused with `400` instead of being sent unprotected.

### Scope and window

- A key is scoped to your application, the consumer, the Unified API, the connector, the operation and `x-apideck-company-id`. For updates, the record id is part of the scope too, so the same key on two different invoices is two different keys.
- The window is 24 hours from the first attempt, or the connector's own window when that is shorter (6 minutes on Xero). After the window, the same key is treated as a new request, so retry within it.
- A key is bound to its first request, even if the connector rejected it. After fixing a rejected body, send it with a new key.
- Some connectors keep their own copy of the key for longer than the window (see the table below). Never reuse a key for a different write.

## Supported connectors and operations

| Connector | [Journal entries create](/apis/accounting/reference/journal-entries#journalEntriesAdd) | [Invoices create](/apis/accounting/reference/invoices#invoicesAdd) | [Invoices update, void](/apis/accounting/reference/invoices#invoicesUpdate) | [Bills create](/apis/accounting/reference/bills#billsAdd) | Window |
| --- | :---: | :---: | :---: | :---: | --- |
| QuickBooks (requestid query parameter) | Yes | Yes | Yes | No | 24 hours |
| Intuit Enterprise Suite (requestid query parameter) | Yes | Yes | Yes | No | 24 hours |
| NetSuite (Key stored as externalId) | Yes | No | No | No | 24 hours |
| Xero (Native Idempotency-Key header) | No | No | No | Yes | 6 minutes |
| Sage Intacct (controlid with uniqueid=true) | In development | No | No | No | Not yet |

Every other connector and operation answers a key with `400 IdempotencyKeyUnsupportedError`, including batch writes and file uploads.

### QuickBooks and Intuit Enterprise Suite

Apideck sends the derived key as the `requestid` query parameter, and QuickBooks returns the original result for a repeated `requestid`. QuickBooks can keep a `requestid` for longer than 24 hours, so a key reused for a different write after the window can return the original record and ignore the new body.

### NetSuite

Apideck writes the derived key as the journal entry's `externalId`. A retry after a create that timed out but was saved returns the original id instead of NetSuite's `DUP_RCRD` error. NetSuite keeps the `externalId` permanently, so use a new key for every journal entry. A `pass_through` that sets `externalId` replaces the key.

### Xero

Apideck forwards the derived key in Xero's own `Idempotency-Key` header. Xero keeps keys for 6 minutes, so retry within minutes. Invoices create on Xero is not covered yet.

### Sage Intacct (in development)

Support uses Sage Intacct's native `controlid` with `uniqueid=true` on the XML gateway, starting with journal entries. The mechanism is verified against Sage Intacct; the implementation has not started. Until it ships, a key returns `400` and nothing is written, so use the lookup pattern below.

## Connectors without key support

When a key is not supported, look the record up before you retry, and only retry when nothing is found:

1. Before the first attempt, set a value you control on the record: the document `number`, `reference` or `source_id`, depending on the resource and connector.
2. On an uncertain outcome, list the resource filtered on that value, for example `filter[bill_number]` for Xero bills or `filter[number]` for QuickBooks journal entries.
3. Retry only when the lookup returns nothing.

This narrows the gap but does not close it: a create that is still in flight when you look it up is not found yet. Space the lookup a few seconds after the failure, and prefer a key wherever one is supported.

>
> Payments, expenses, credit notes and refunds do not accept a key on any connector yet. Use the
> lookup pattern for them.

## Choosing keys

- **One key per intended write.** Build it from your own record, for example `invoice-{your_invoice_id}` or `payroll-{run_id}-{entity}`. A new write gets a new key; a retry of the same write reuses it.
- **Store the key with the pending write** in your outbox or job record before the first attempt, so a worker that crashes and restarts retries with the same key.
- **Keep the body identical across retries.** Rebuild it from the same stored inputs, not from data that may have changed in between. A changed body returns `422`.
- **Retry within the window,** with backoff. On Xero that means minutes, not hours.
- **Do not generate a fresh key per attempt.** It looks correct and deduplicates nothing.
