# Idempotency and batch

A timeout doesn't tell you whether BrokerPlus made the record. Send the same request again without
protection and you can end up with two contacts, two leads, or two tasks. This page covers the two
tools that prevent that: the `Idempotency-Key` header for single requests, and `external_id` for
batch writes.

## The `Idempotency-Key` header

Make up a unique key for each new operation, such as a UUID, and send it on every `POST`. If you
retry with the same key and the same request, the API returns the response it stored the first
time, with an `Idempotent-Replay: true` header, and does not do the work again.

<CodeTabs syncKey="lang">

```bash title="curl"
curl -X POST https://api.mos.getbrokerplus.ca/v1/contacts \
  -H "Authorization: Bearer $BROKERPLUS_API_KEY" \
  -H "Idempotency-Key: 8f14e45f-ea0a-4c1b-9a1e-2b7c9d3e5f60" \
  -H "Content-Type: application/json" \
  -d '{"first_name":"Sam","last_name":"Client","email":"sam@example.com"}'
```

```ts title="TypeScript"
// Make the key once, and reuse it for every retry of this operation.
const idempotencyKey = crypto.randomUUID();
const body = JSON.stringify({ first_name: "Sam", last_name: "Client", email: "sam@example.com" });

const res = await fetch("https://api.mos.getbrokerplus.ca/v1/contacts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BROKERPLUS_API_KEY}`,
    "Idempotency-Key": idempotencyKey,
    "Content-Type": "application/json",
  },
  body,
});
```

```python title="Python"
import json
import os
import uuid

import requests

# Make the key once, and reuse it for every retry of this operation.
idempotency_key = str(uuid.uuid4())
body = json.dumps({"first_name": "Sam", "last_name": "Client", "email": "sam@example.com"})

res = requests.post(
    "https://api.mos.getbrokerplus.ca/v1/contacts",
    data=body,
    headers={
        "Authorization": f"Bearer {os.environ['BROKERPLUS_API_KEY']}",
        "Idempotency-Key": idempotency_key,
        "Content-Type": "application/json",
    },
)
```

</CodeTabs>

**A retry must send exactly the same bytes.** Serialize the body once and send that string on every
attempt. Different whitespace or a different key order counts as a different request.

A good key comes from your own data, so a crashed job finds the same key when it restarts. For
example, `webform-<submission id>` or `zoho-contact-<old id>`.

### What the header covers

| Rule        | Detail                                                                                                  |
| ----------- | ------------------------------------------------------------------------------------------------------- |
| Methods     | `POST` only. `PATCH` and `DELETE` are not protected.                                                    |
| Lifetime    | 24 hours. After that the key is forgotten, which does not mean the first request failed.                |
| Ownership   | A key belongs to the API key and broker that first used it. Key strings are unique across the brokerage. |
| Match       | The method, path, and body bytes must all match.                                                        |
| Access      | If you can no longer see the record the first request made, the replay returns `404` and changes nothing. |
| Optional    | Without the header, nothing is stored and a retry is a new request.                                     |

The header makes a retry safe, but it is not an exactly-once guarantee. The record write and the
stored response are separate steps, so a server error can land between them. After a `5xx` or a
timeout, check the record before you send a request with a **new** key.

### Conflicts

| Response                          | What happened, and what to do                                                                                                  |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `409 IDEMPOTENCY_KEY_IN_FLIGHT`   | The first request is still running. Wait, then retry with the same key and body. If it persists, check whether the record exists. |
| `409 IDEMPOTENCY_KEY_CLAIMED`     | Another API key or broker owns this key. Nothing ran. Don't switch to a new key until you know whether the first request worked, or you can make a duplicate. |
| `422 IDEMPOTENCY_KEY_REUSED`      | The key is yours, but the request differs from the first one. Send the original method, path, and body, or use a new key for a genuinely new operation. |

A request that fails validation before it changes anything releases its key. Fix the body and try
again with the same key.

## Batch writes

Four endpoints create or update up to **100 records** in one request:

- `POST /v1/contacts/batch`
- `POST /v1/leads/batch`
- `POST /v1/deals/batch`
- `POST /v1/tasks/batch`

Each item is the body you would send to the single create (`POST /v1/contacts`) or, for a record
that exists, the single update (`PATCH /v1/contacts/{id}`). The single endpoint checks each item, so
the rules and errors are the same.

```bash
curl -X POST https://api.mos.getbrokerplus.ca/v1/contacts/batch \
  -H "Authorization: Bearer $BROKERPLUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "namespace": "zoho",
    "items": [
      { "external_id": "4410001", "data": { "first_name": "Sam", "last_name": "Client", "email": "sam@example.com" } },
      { "external_id": "4410002", "data": { "first_name": "Jordan", "email": "not-an-email" } }
    ]
  }'
```

```json
{
  "success": true,
  "data": {
    "created": 1,
    "updated": 0,
    "failed": 1,
    "results": [
      { "object": "batch_item_result", "index": 0, "status": "created", "id": "cmsclk2a9...", "external_id": "4410001", "error": null },
      {
        "object": "batch_item_result",
        "index": 1,
        "status": "failed",
        "id": null,
        "external_id": "4410002",
        "error": { "status": 400, "code": "VALIDATION_ERROR", "message": "Invalid email" }
      }
    ]
  }
}
```

How a batch behaves:

- **Send `external_id` with a `namespace`**, and BrokerPlus remembers the pair. The next batch with
  the same `external_id` updates that record instead of making a new one. The `namespace` is the
  name of the system your ids come from, such as `zoho`, so two systems can use the same id.
- To update a record you already know, send its BrokerPlus `id`. Send `id` and `external_id`
  together to link your id to a record that is already in BrokerPlus, so later batches find it.
- If the record an `external_id` names was deleted or merged in BrokerPlus, that item fails with
  `404 NOT_FOUND` and nothing is written. BrokerPlus does not quietly re-create what the broker
  removed.
- **Items are not one transaction.** A failed item doesn't stop the others. Read `results`, one per
  item in the order you sent them, and retry only the items that failed.
- An `external_id` that already names a different record fails that item with `409 EXTERNAL_ID_TAKEN`.
  Two requests racing on the same new `external_id` fail one item with `409 EXTERNAL_ID_IN_FLIGHT`;
  send it again shortly.
- A batch that is invalid as a whole (no items, more than 100, the same `external_id` twice, or an
  `external_id` without `namespace`) returns `400` and writes nothing.
- **Each item counts as one request** against the [rate limit](/authentication#rate-limit).
- A batch is a `POST`, so the `Idempotency-Key` header works on it too. A retry with the same key
  replays the whole batch response.

The [Migrate from another CRM](/migrate-from-a-crm) guide uses batch writes for a full import.

To complete, cancel, or reopen many tasks at once, use `POST /v1/tasks/bulk` (up to 200 tasks). It
applies one action and lists the tasks it changed and skipped.
