> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dualentry.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Idempotency and Write Validation

> Use the Idempotency-Key header to make DualEntry Public API writes safe to retry, and see which fields the API requires on posted records.

Write requests to the DualEntry Public API are validated more strictly than the same records created in the interface, and a retried request creates a second record unless you tell the API it is a retry. This page covers the `Idempotency-Key` header and the validation rules that most often surprise integration authors.

## Send an idempotency key on every write

The DualEntry Public API accepts an `Idempotency-Key` header on write requests and uses it to collapse retries of the same write into a single result. The header applies to `POST`, `PUT`, `PATCH`, and `DELETE` on any `/public/` path, on both V1 and V2. Send it, and a retry returns the stored response from the first request instead of creating a second record. Omit it, and a client that retries after a timeout, a dropped connection, or an ambiguous gateway error creates a second copy of the record, leaving you to archive the extra records afterwards.

Generate a fresh key per logical write, not per HTTP attempt. A UUID version 4 value is a good default. Reuse the same key across every retry of that one write, and never reuse a key for a different payload.

DualEntry scopes a key to your organization, the HTTP method, and the request path. The same key sent to a different endpoint is treated as a different write, and a key keeps working if you rotate your API key partway through a retry sequence. Results stay replayable for 48 hours, after which the key expires and a repeat request runs as a new write.

The example below posts a journal entry with an idempotency key so the request can be retried safely.

```bash theme={null}
curl -X POST "https://api.dualentry.com/public/v2/journal-entries/" \
  -H "X-API-KEY: $DUALENTRY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 3f0c8d1e-9a4b-4c7e-8f2a-1b6d5e3c9a70" \
  -d @journal_entry.json
```

Every response to a request carrying the header echoes the key back in an `Idempotency-Key` response header. A replayed response also carries `Idempotency-Replayed: true`, which is the only reliable way to tell a replay from a fresh write.

<Warning>
  A client that pushes a batch of records without idempotency keys and then retries the batch posts the whole batch twice. Recovering means archiving the duplicate records one by one, so set the header before you run any bulk load.
</Warning>

## How the API responds to a repeated idempotency key

The response to a repeated `Idempotency-Key` depends on the state of the first request, so a client that retries needs to handle more than a straight replay. The table below lists every outcome the DualEntry Public API returns for a key it has already seen.

| Situation                                                        | Status              | What the API returns                                                                |
| ---------------------------------------------------------------- | ------------------- | ----------------------------------------------------------------------------------- |
| First request finished, same payload                             | The original status | The stored response body, plus `Idempotency-Replayed: true`                         |
| First request still running                                      | `409`               | A message that the request is still being processed, plus a `Retry-After` header    |
| Same key, different payload                                      | `422`               | A message that the key was already used with a different request body               |
| Original response larger than 256 KB                             | `409`               | A message that the original response cannot be replayed. The write was not repeated |
| Idempotency storage unavailable                                  | `503`               | A message asking you to retry with the same key                                     |
| Key is empty, longer than 255 characters, or not printable ASCII | `400`               | A message describing the key requirement                                            |
| Key sent on a request whose body is not JSON                     | `400`               | A message that the header is only supported on JSON requests                        |

Treat the `409` for an in-flight request as "retry shortly," not as a failure. A client that retries immediately after a timeout is the case most likely to hit it, because the first request is often still running.

DualEntry only stores a response that represents a decision about your write. A `5xx`, `401`, `403`, `408`, or `429` response releases the key instead, so retrying with the same key runs the write again rather than replaying the failure.

## Fields the API requires on posted records

A create request defaults to `record_status: "posted"`, and a posted record must carry every field its record type requires. When one is missing, the API answers with `Required for posted records:` followed by the field names, for example `Required for posted records: due_date, exchange_rate`.

The table below lists the fields the DualEntry Public API enforces on posted invoices, bills, and journal entries.

| Field                    | Type    | Invoices          | Bills                           | Journal entries   | Notes                                                              |
| ------------------------ | ------- | ----------------- | ------------------------------- | ----------------- | ------------------------------------------------------------------ |
| `company_id`             | integer | Required          | Required                        | Required          | The legal entity the record posts to                               |
| `customer_id`            | integer | Required          | Not applicable                  | Not applicable    |                                                                    |
| `vendor_id`              | integer | Not applicable    | Required                        | Not applicable    |                                                                    |
| `currency_iso_4217_code` | string  | Required          | Required                        | Required          | Required even when the currency matches the company base currency  |
| `exchange_rate`          | decimal | Required          | Required                        | Required          | Send `1.0` in a single-currency organization                       |
| `memo`                   | string  | Not required      | Required                        | Required          | A posted invoice sent without a `memo` is stored with an empty one |
| `due_date`               | date    | Required          | Supplied or derived             | Not applicable    | Bills resolve this differently, as described below                 |
| Lines                    | array   | One `items` entry | One `items` or `expenses` entry | One `items` entry | A posted record needs at least one line                            |

Bills resolve `due_date` differently from invoices. It is not part of the posted-record check, but a bill cannot be stored without one. Send `due_date` directly, or send a `term_id` and let DualEntry calculate the date from that term. If you send neither, DualEntry falls back to the vendor's default term, and then to the term on a linked purchase order. A bill with no due date and no term to derive one from fails on save with a different error, and this applies to draft bills as well as posted ones.

Create the record with `record_status: "draft"` when you do not have all of these values yet. A draft skips both the required-field check and the line check, and receives defaults instead: an empty `memo`, an `exchange_rate` of `1.0`, and `USD` as the currency. Patch the record and post it once the missing values arrive.

## Line item validation rules

Lines carry their own required fields, and those apply to drafts as well as posted records, so a draft does not let you defer them the way it defers record level fields. Which fields a line needs depends on the kind of line, and the three record types do not share one shape.

The table below lists the fields required on each kind of line in the DualEntry Public API.

| Line type             | Required fields                                                         | Notes                                            |
| --------------------- | ----------------------------------------------------------------------- | ------------------------------------------------ |
| Invoice `items`       | `item_id`, `quantity`, `rate`, `position`, `memo`                       |                                                  |
| Bill `items`          | `item_id`, `quantity`, `rate`, `position`, `memo`                       | Same shape as an invoice line                    |
| Bill `expenses`       | `account_number`, `expense_description`, `quantity`, `rate`, `position` | Carries `expense_description` rather than `memo` |
| Journal entry `items` | `account_number`, `debit`, `credit`, `position`, `memo`                 | Carries no `quantity` or `rate`                  |

Two rules account for most line level failures on a first integration.

* **The text field has no default.** A line that omits `memo`, or `expense_description` on a bill expense line, fails schema validation even when the parent record is a draft. An empty string is accepted, so send `""` when there is nothing to record against the line.
* **Send a quantity greater than zero.** On the line types that carry `quantity`, the field is constrained to values above zero. A zero quantity line is not a way to carry a description only line or a placeholder, so remove the line instead of zeroing it.

The `id` field on a line is optional. Send it to update an existing line on a `PUT`, and omit it to create a new one. Classifications and an intercompany `company_id` are optional on every line type that supports them.

## Fields the API does not expose

Some fields and actions available in the DualEntry interface have no Public API equivalent, so an integration cannot round-trip them. Check this section before you design a sync that assumes the two surfaces are at parity:

* **Item pricing**: The `default_standalone_selling_price` on an item cannot be read, set, or updated through the Public API, on either V1 or V2. The item schemas do expose the accounting configuration needed for posting, including `income_account_id`, `expense_account_id`, `deferral_account_id`, `asset_account_id`, `product_tax_code_id`, and the revenue recognition defaults, but they carry no price field. Maintain item prices in the interface.
* **Contract editing on V1**: The V1 contract endpoints cover creating and reading a contract, plus reading and upserting obligation usage. They do not update a contract or expose its change orders. V2 additionally supports updating a contract and managing its change orders, so check the version you are calling before you plan around this.

For the full field list on each resource, see the V2 API reference for that record type, such as [Create InvoiceV2 record](/developers/api/resources-v2/invoices/create-invoicev2-record). For status codes and the shape of an error response, see the [error handling guide](./errors). For end to end integration patterns, see [Building a custom integration](./building-a-custom-integration).
