# Idempotency

How to use idempotency keys to prevent duplicate operations in the BeeL. API.

## What is idempotency?

Idempotency ensures that an operation can be executed multiple times with the same result. In APIs, this is crucial to avoid creating duplicate resources when there are network issues or application errors.

## How it works

BeeL. implements idempotency via the standard `Idempotency-Key` header. When you include this header in a **POST** request, the API:

1. Stores the idempotency key along with the operation result — a success (`2xx`) **or a server error (`5xx`)**
2. If it receives the same request with the same key, returns the stored result and adds `Idempotency-Replay: true`
3. Does not execute the operation again

A client error (`4xx`) is **not** stored: the request was rejected before anything happened, so the key is free again. See [Error Handling](#error-handling).

This prevents duplicate invoices, customers, or other resources even if your application retries the request.

<Callout type="warn">
  **The header name is `Idempotency-Key`** — exactly that. Any other spelling (`X-Idempotency-Key`, `idempotency_key`, …) is not recognised and is dropped **silently**: the request succeeds, no protection is applied, and a retry creates a second invoice.
</Callout>

The key is scoped **per user, per company and per environment** (a sandbox key never collides with a production one) and is **bound to the operation, its query string and the request body**, so a replay only happens for the exact same operation.

<Callout type="warn">
  **`Idempotency-Key` is not "one invoice per order".** It protects against *retries of the same request* (network timeouts, double-submits) for 24 hours — it identifies the **request**, not your business object, and it deliberately keeps returning the original response **even if you later delete** the created invoice. If what you want is "at most one live invoice per order, and let me recreate it after deleting", that's a **business key** — use [`external_ref`](#one-invoice-per-order), not the idempotency key.
</Callout>

## Recommended usage

### POST requests (creation)

**Always** use `Idempotency-Key` when creating resources:

```bash
curl -X POST "https://app.beel.es/api/v1/companies/{company_id}/invoices" \
  -H "Authorization: Bearer $BEEL_API_KEY" \
  -H "Idempotency-Key: invoice-order-12345" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "STANDARD",
    "operation_date": "2025-01-15",
    "recipient": {
      "customer_id": "550e8400-e29b-41d4-a716-446655440000"
    },
    "lines": [
      {
        "description": "Consulting",
        "quantity": 1,
        "unit_price": 100.00,
        "main_tax": { "type": "IVA", "percentage": 21, "regime_key": "01" }
      }
    ]
  }'
```

### Updates (PUT / PATCH)

Updates usually need no key: they target an existing resource, so repeating one converges on the same state instead of creating a second resource. `POST` always honours `Idempotency-Key`; some `PUT`, `PATCH` and `DELETE` operations honour it too, with the same rules. Rely on it outside `POST` only where the API reference lists the header for that operation.

Resources are edited with `PATCH`, which touches only the fields you send — see [API conventions](/guides#api-conventions):

```bash
curl -X PATCH "https://app.beel.es/api/v1/companies/{company_id}/customers/550e8400-e29b-41d4-a716-446655440000" \
  -H "Authorization: Bearer $BEEL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "address": {
      "street": "New address 123",
      "number": "45",
      "postal_code": "28001",
      "city": "Madrid",
      "province": "Madrid",
      "country": "España"
    }
  }'
```

<Callout type="info">
  **API plan exclusive:** Idempotency support is available only with the API plan. See [Pricing](/pricing) for details.
</Callout>

## Key generation

The idempotency key must be **unique** for each logical operation you want to perform.

Format rules — a key that breaks them is rejected with `400` [`INVALID_IDEMPOTENCY_KEY`](/errors/INVALID_IDEMPOTENCY_KEY):

- Letters, digits, `-` and `_` only (a UUID qualifies, but is not required)
- Maximum 255 characters
- Non-empty

### ✅ Best practices

```js
// Use UUID v4
import { v4 as uuidv4 } from 'uuid';
const idempotencyKey = uuidv4(); // "550e8400-e29b-41d4-a716-446655440000"

// Deterministic for ONE attempt: stable so a network retry of that attempt reuses it.
// A new attempt (e.g. after you deleted the previous invoice) needs a NEW key.
const idempotencyKey = `invoice-order-${orderId}-${attemptId}`;

// Want "one invoice per order" as a durable rule? That's `external_ref`
// (see below), not the idempotency key.

// Hash-based (deterministic for same input)
import crypto from 'crypto';
const idempotencyKey = crypto
  .createHash('sha256')
  .update(JSON.stringify({ orderId, customerId }))
  .digest('hex');
```

### ❌ Bad practices

```js
// DON'T use values that repeat across different operations
const idempotencyKey = 'create-invoice'; // ❌ Too generic, will collide

// DON'T use timestamps (can collide in high-traffic scenarios)
const idempotencyKey = Date.now().toString(); // ❌ Not unique enough

// DON'T use random values (can't retry with same key)
const idempotencyKey = Math.random().toString(); // ❌ Non-deterministic
```

## Usage scenarios

### Problem: Network error

**Without idempotency:**
```text
1. Client sends request → Timeout (no response)
2. Client retries → Creates second invoice ❌
3. Result: Duplicate invoices, angry customer
```

**With idempotency:**
```text
1. Client sends request with key "abc123" → Timeout
2. Client retries with same key "abc123" → API detects duplicate
3. Result: Returns original invoice ✅
```

### Problem: Automatic retry logic

```js
// ❌ Without idempotency - DANGER
async function createInvoice(data) {
  try {
    return await api.post('/v1/companies/{company_id}/invoices', data);
  } catch (error) {
    if (error.code === 'NETWORK_ERROR') {
      return await api.post('/v1/companies/{company_id}/invoices', data); // Creates duplicate!
    }
  }
}

// ✅ With idempotency - SAFE
async function createInvoice(data, idempotencyKey) {
  const headers = { 
    'Authorization': `Bearer ${process.env.BEEL_API_KEY}`,
    'Idempotency-Key': idempotencyKey 
  };

  try {
    return await api.post('/v1/companies/{company_id}/invoices', data, { headers });
  } catch (error) {
    if (error.code === 'NETWORK_ERROR') {
      // Same key ensures we get the original response
      return await api.post('/v1/companies/{company_id}/invoices', data, { headers }); // ✅ Safe
    }
  }
}
```

### Problem: Concurrent requests

```js
// ✅ Multiple systems creating the same logical invoice
// (e.g., Shopify + manual ERP sync)

// System A
await createInvoice(invoiceData, `shopify-order-${orderId}`);

// System B (runs concurrently)
await createInvoice(invoiceData, `shopify-order-${orderId}`);

// Result: Only ONE invoice is created ✅ (same key AND same body)
// While the first is running, the second gets 409 IDEMPOTENCY_KEY_PROCESSING;
// retried with the same key after Retry-After, it gets the first invoice
```

<Callout type="info">
  This dedupes concurrent retries **within the 24-hour window**. For a durable "one invoice per order" guarantee that persists across days and reflects deletions, also set [`external_ref`](#one-invoice-per-order) on the invoice.
</Callout>

## Lifetime

Idempotency keys are stored for **24 hours**. After this time:
- The key can be reused for a new operation
- Attempting to retry with the same key will create a new resource

<Callout type="warn">
  **24-hour limit:** If you need to retry a failed operation after 24 hours, generate a new idempotency key.
</Callout>

## One invoice per order

`Idempotency-Key` protects **requests**; `external_ref` protects your **business object**. Pass your own order / cart / contract id when creating an invoice:

```bash
curl -X POST "https://app.beel.es/api/v1/companies/{company_id}/invoices" \
  -H "Authorization: Bearer $BEEL_API_KEY" \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
  -H "Content-Type: application/json" \
  -d '{
    "external_ref": "ORD-2025-0042",
    "type": "STANDARD",
    "recipient": { "customer_id": "550e8400-e29b-41d4-a716-446655440000" },
    "lines": [{ "description": "Consulting", "quantity": 1, "unit_price": 100.00,
                "main_tax": { "type": "IVA", "percentage": 21, "regime_key": "01" } }]
  }'
```

BeeL. then enforces **at most one live standard/simplified invoice per `external_ref`**, unique per issuer:

- A second create with the same `external_ref` returns **`409` [`INVOICE_DUPLICATE_EXTERNAL_REFERENCE`](/errors/INVOICE_DUPLICATE_EXTERNAL_REFERENCE)**.
- **Delete that invoice and you can recreate it** with the same reference — unlike the idempotency key, this reflects the *current* state.
- The rule covers live standard and simplified invoices; other document types are not constrained by it.
- Fetch the existing invoice anytime: `GET /v1/companies/{company_id}/invoices?external_ref=ORD-2025-0042`.

<Callout type="info">
  **The field used to be called `external_reference`.** The old name is still accepted as an alias — on the request body and on the deprecated flat route `GET /v1/invoices?external_reference=…` — but it will be withdrawn in a future major version. Send `external_ref`, and filter on the company route above.
</Callout>

<Callout type="info">
  **Which do I use? Both, for different jobs.** Generate a **fresh `Idempotency-Key` (UUID) per creation attempt** so a network retry can't duplicate. Use **`external_ref`** to tie the invoice to your order and let BeeL. reject business duplicates. Don't encode your order id in the idempotency key to get "one per order" — that's exactly what `external_ref` is for.
</Callout>

## Response behavior

### First request (successful)

```http
HTTP/1.1 201 Created
Content-Type: application/json
Location: /v1/companies/{company_id}/invoices/550e8400-e29b-41d4-a716-446655440000

{
  "success": true,
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "invoice_number": "FAC-2025-0001",
    "status": "DRAFT",
    ...
  }
}
```

### Duplicate request (with same key)

```http
HTTP/1.1 201 Created
Content-Type: application/json
Location: /v1/companies/{company_id}/invoices/550e8400-e29b-41d4-a716-446655440000
Idempotency-Replay: true

{
  "success": true,
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "invoice_number": "FAC-2025-0001",
    "status": "DRAFT",
    ...
  }
}
```

**Note:** `Idempotency-Replay: true` is the **only** signal that tells "I created this now" apart from "here is the one from before". A replay repeats the original status code, body and `Location`, so branch on the header, not on the status. A first response carries no `Idempotency-Replay` header at all.

## Idempotency errors

Three responses come from the idempotency layer itself, before your operation runs:

| Error | When | What to do |
|---|---|---|
| `400` [`INVALID_IDEMPOTENCY_KEY`](/errors/INVALID_IDEMPOTENCY_KEY) | The key breaks the format rules above | Fix the key |
| `409` [`IDEMPOTENCY_KEY_PROCESSING`](/errors/IDEMPOTENCY_KEY_PROCESSING) | The first request with this key is **still in flight** (concurrent retry) | Wait the `Retry-After` seconds (2) and retry with the **same** key |
| `409` [`IDEMPOTENCY_KEY_MISMATCH`](/errors/IDEMPOTENCY_KEY_MISMATCH) | The key was already used with a **different body or a different path** | Use a **new** key — the key identifies one operation, not one endpoint |

## Error handling

What happens to the key depends on how the first request ended:

| First response | Stored? | Retry with the same key |
|---|---|---|
| `2xx` | Yes, for 24 hours | Replays the stored success (`Idempotency-Replay: true`) |
| `4xx` | **No** — the key is released | Runs the operation again |
| `5xx` | **Yes**, for 24 hours | Replays **the same `5xx`** (`Idempotency-Replay: true`); nothing runs again |

### Client errors (`4xx`)

A `4xx` means the request was rejected before anything happened, so nothing is stored under the key. Fix the payload and retry:

```js
// First request fails with a validation error
try {
  await createInvoice(invalidData, 'key-123');
} catch (error) {
  console.log(error.status); // 422 — nothing was stored under 'key-123'
}

// Fix the payload and retry. 'key-123' is free again, so reusing it works;
// a new key works too.
await createInvoice(correctedData, 'key-123');
```

### Server errors (`5xx`)

A `5xx` **is** stored, because the failure may have happened *after* the work was done: the invoice was issued, the email was sent, and only the response failed. Retrying with the same key returns that same `5xx` instead of running the operation a second time, which could duplicate it.

So a `5xx` is not a free retry. To try again:

1. **Check whether the operation actually happened.** For an invoice, look it up by the reference you sent: `GET /v1/companies/{company_id}/invoices?external_ref=ORD-2025-0042`, or by a metadata pair: `?metadata[order_id]=ORD-2025-0042`. For anything else, look up the resource the same way you would find it in your own records.
2. **If it exists**, use it. Don't retry.
3. **If it does not exist**, retry with a **new** `Idempotency-Key`.

<Callout type="warn">
  **A new key always re-executes.** If you skip the check and the first attempt did commit, the new key creates a duplicate. Setting `external_ref` on invoices is the safety net: a duplicate create is rejected with `409` [`INVOICE_DUPLICATE_EXTERNAL_REFERENCE`](/errors/INVOICE_DUPLICATE_EXTERNAL_REFERENCE) instead of issuing a second invoice.
</Callout>

If a retry with the same key keeps returning `409` [`IDEMPOTENCY_KEY_PROCESSING`](/errors/IDEMPOTENCY_KEY_PROCESSING) long after a request would normally finish, the first attempt never reported an outcome. Treat it like a `5xx`: check, then use a new key.

Rule of thumb: **same body → same key** (safe retry after a network error), **changed body → new key**, **after a `5xx` → check, then new key**.

## Limitations

| Aspect | Limit |
|--------|-------|
| **Lifetime** | 24 hours |
| **Max length** | 255 characters |
| **Allowed characters** | Letters, digits, `-`, `_` |
| **Applies to** | Every POST, plus the PUT, PATCH and DELETE operations whose reference lists the header |
| **Does NOT apply to** | GET (the header is ignored) |
| **Responses stored** | `2xx` and `5xx`; a `4xx` releases the key |
| **Scope** | Per user, per company and per environment (sandbox keys never collide with production ones) |
| **Availability** | API plan only |

## Official SDK support

The [Node.js SDK](/sdks/node) (`@beel_es/sdk`) (2.1.0 or later) applies these rules for you:

| | Behaviour |
|---|---|
| Every `POST` | Carries an `Idempotency-Key` (a UUID) unless you pass one |
| Automatic retries (`429`, [`IDEMPOTENCY_KEY_PROCESSING`](/errors/IDEMPOTENCY_KEY_PROCESSING), `5xx` and network errors on a `POST`) | Reuse **the same key**, so a retry never creates a second resource. A replayed `5xx` is final and is not retried |
| Your own key | `{ idempotencyKey }` as the last argument of any `company.invoices` method, sent on every attempt of that call |
| `409` and `422` | Thrown with the API's code in `apiCode` |

One call is one key; a new call is a new key. So when a create finally fails with an unknown outcome, do not simply call it again. For invoices tied to an order, `createOnce` does the check-then-create for you: it looks the invoice up by `external_ref`, creates it only if nothing holds the reference, and after a duplicate or an unknown outcome returns the one that exists. This is the pattern of [From order to invoice](/guides/order-to-invoice#issue-once):

```ts title="issue-once.ts"
import { company, CALL } from './client';
import type { Invoice, OrderInvoice } from './types';

/**
 * The invoice of one order, created at most once: `external_ref` is the order id.
 * Safe to call again after any error, and on every rerun of the batch.
 * The invoice returned may be one created earlier, VOIDED included: check its status.
 */
export function invoiceOrder(body: OrderInvoice): Promise<Invoice> {
  return company.invoices.createOnce(body, undefined, CALL);
}
```

For other operations, store one key per operation with your own record before the first attempt, and send it on every attempt:

```ts title="create-with-key.ts"
import { BeeL, type components } from '@beel_es/sdk';

type CreateInvoiceRequest = components['schemas']['CreateInvoiceRequest'];

const beel = new BeeL({ apiKey: process.env.BEEL_API_KEY! });
const company = beel.company(process.env.BEEL_COMPANY_ID!);

/**
 * Your own key, stored with the order before the first attempt. Every attempt —
 * the SDK's automatic retries and your own later calls — sends the same key, so
 * within 24 hours the API replays the first answer instead of creating again.
 */
export async function createWithStoredKey(body: CreateInvoiceRequest, storedKey: string) {
  return company.invoices.create(body, undefined, { idempotencyKey: storedKey, timeoutMs: 20_000 });
}
```

## Best practices summary

✅ **Do:**
- Use UUIDs or hash-based keys for uniqueness
- Include an idempotency key in ALL POST requests that create something
- Spell the header exactly `Idempotency-Key`
- Store the key alongside the request in your database
- Retry with the SAME key on network errors and timeouts (no response received)
- After a `5xx`, check whether the operation happened before retrying with a NEW key
- Use descriptive key names for debugging (e.g., `invoice-shopify-order-${id}`)

❌ **Don't:**
- Use generic or repeated values (`'create-invoice'`)
- Generate random keys that can't be reproduced
- Rely solely on timestamps (can collide)
- Forget to implement retries with the same key
- Use idempotency keys longer than 255 characters

## Real-world example

With the SDK, the loop is `createOnce` (or a stored key), shown in [Official SDK support](#official-sdk-support). Over plain HTTP, the loop has to make the same decisions on each response:

| Response | Do |
|---|---|
| No response | Retry with the **same** key |
| `2xx` | Done. `Idempotency-Replay: true` means it is the invoice from before |
| `409` [`IDEMPOTENCY_KEY_PROCESSING`](/errors/IDEMPOTENCY_KEY_PROCESSING) | Wait `Retry-After`, retry with the **same** key |
| `409` [`INVOICE_DUPLICATE_EXTERNAL_REFERENCE`](/errors/INVOICE_DUPLICATE_EXTERNAL_REFERENCE) | Fetch it: `GET /v1/companies/{company_id}/invoices?external_ref=…` |
| Other `4xx` | Fix the request |
| `5xx` | Look it up by `external_ref`; if it is not there, retry with a **new** key |

> **Rules that apply here:** [LIF-004 · Retry writes with the same Idempotency-Key](/rules/lifecycle#lif-004)

## Related

<Related>

- [Handling errors](/guides/handling-errors) — retry rules and key semantics per status code
- [Create an invoice](/invoices/createCompanyInvoice) — practical API reference
- [Node.js SDK](/sdks/node) — built-in idempotency support out of the box

</Related>

Questions? Email us at [it@beel.es](mailto:it@beel.es).

---

Full OpenAPI spec: https://docs.beel.es/api/openapi