# 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, 1 hour on Sage Intacct REST). 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) | [Credit notes create](/apis/accounting/reference/credit-notes#creditNotesAdd) | [Bills create](/apis/accounting/reference/bills#billsAdd) | Window |
| --- | :---: | :---: | :---: | :---: | :---: | --- |
| QuickBooks (requestid query parameter) | Yes | Yes | Yes | No | No | 24 hours |
| Intuit Enterprise Suite (requestid query parameter) | Yes | Yes | Yes | No | No | 24 hours |
| NetSuite (Key stored as externalId) | Yes | No | No | No | No | 24 hours |
| Xero (Native Idempotency-Key header) | No | No | No | No | Yes | 6 minutes |
| Sage Intacct (controlid with uniqueid=true) | Yes | Yes | No | Yes | No | 24 hours |
| Sage Intacct REST (Native Idempotency-Key header) | Yes | Yes | No | Yes | No | 1 hour |

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

Apideck sends the derived key as the function `controlid` with `uniqueid=true`, so Sage Intacct refuses a second write with the same key. Sage does not return the original record with that refusal, so Apideck looks it up by the values you sent: `source_id` for journal entries, and `number` with the customer for invoices and credit notes. When exactly one record matches, the retry returns `201` with its id. Otherwise it returns Sage's `400` error `XL03000010` and nothing is written.

- Set `source_id` on journal entries and `number` on invoices and credit notes, so a retry can return the original id. To keep invoice and credit note numbers unique per customer, set **If duplicate numbers** to **Do not allow transactions to be created** in the company's Accounts Receivable configuration.
- On a `400` `XL03000010`, the first write was recorded. Look it up instead of sending it again with a new key: `filter[source_id]` on journal entries, or `filter[number]` on invoices and credit notes, keeping the result for your customer.
- In companies that use document sequencing, Sage Intacct replaces the `number` you send, so an invoice or credit note retry returns the `400`, not the id.
- A `pass_through` that sets the function `controlid` replaces the key, and a retry returns the `400` instead of the id.
- Sage does not document how long it keeps a control id. Apideck applies its 24-hour window; if Sage forgets a key sooner, a retry after that creates a second record, so retry soon after the failure.

### Sage Intacct REST

Apideck forwards the derived key in Sage Intacct's own `Idempotency-Key` header, and Sage answers a repeat with the original response instead of creating a second record. Sage does not publish how long it keeps a key; it still replayed the original after an hour, so Apideck remembers a key for 1 hour. Retry within that hour. After it, Sage may still recognise the key: the same body returns the original record and a different body returns `422`, so use a new key for every write.

## 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 and refunds do not accept a key on any connector yet, and credit notes only
> on the two Sage Intacct connectors. 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; on Sage Intacct REST, within the hour.
- **Do not generate a fresh key per attempt.** It looks correct and deduplicates nothing.
