# Idempotency

> Safe retries in the TimeTracking API: the Idempotency-Key header, upsert by external ID, client-generated claim IDs, and which endpoints honour which mechanism.

URL: https://timetracking.tech/developers/api/idempotency
Last updated: 2026-10-24

Networks fail. A retried write must not create duplicate hours or people. TimeTracking offers three mechanisms.

| Mechanism | Scope | Use for |
|---|---|---|
| `Idempotency-Key` header | every authenticated `POST`/`PUT`/`PATCH`/`DELETE` route | Any retry of a write |
| `PUT /…/by-external/{source}/{ref}` | people, clients, placements | Syncing master data from another system: naturally idempotent |
| Client-generated `id` on `POST /claims` | claims | Offline-first apps; dedupe at claim level |

## `Idempotency-Key` header

```sh
curl -s -X POST "$TT/claims" \
  -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -H "Idempotency-Key: bullhorn-timecard-881-2026-12-10" \
  -d '{"placementId":"96574dce-db96-4da1-82cf-de32021472d9","workDate":"2026-12-10","minutes":450,"source":"manual_office"}'
```

Rules:

- The key is 8–200 characters, unique per org. Use a UUID or a deterministic key derived from your source record.
- The **first successful (2xx)** response is stored for **24 hours**. A retry with the same key, method and path returns that stored response with the header `idempotent-replayed: true`. The handler does not run again.
- Failed responses (4xx/5xx) are not stored. You can fix the request and retry with the same key.
- Reusing a key with a **different method or path** → 422 `{"code":"invalid","message":"Idempotency-Key was already used for a different request"}`.
- A key of the wrong length → 422 `{"code":"invalid","message":"Idempotency-Key must be 8–200 characters"}`.
- The body is **not** compared. Same key, same path, different body → the stored response is replayed. Derive keys from the content you send.

Captured replay:

```http
HTTP/1.1 200 OK
idempotent-replayed: true
content-type: application/json

{"data":{"id":"498795f4-4027-4888-84a6-885952f0ab8b","minutes":450,"status":"draft", "…": "…"}}
```

### Where it applies

Every authenticated write route honours the header, including `POST /exports` (preview and commit), `/days/approve`, `/approval-links`, `/conflicts/{id}/resolve`, `/import`, `/periods/lock`, `/weeks/submit`, the batch endpoints and the by-external upserts. Captured replay on `POST /days/approve`:

```http
HTTP/1.1 200 OK
idempotent-replayed: true

{"data":{"skipped":1,"approved":0,"waitingForClient":0}}
```

The public client-approval routes (`/client/approve/{token}`, `/code`, `/verify`) take no credential and do not use the header.

Use a key on **`POST /approval-links`** in particular: every un-keyed call creates and emails a new link. Use one on **export commits** too: a replay returns the original `{runId, …}` and does not try to export again.

## Upsert by external ID

`PUT /people/by-external/{source}/{ref}` (and `/clients/…`, `/placements/…`) creates the record the first time and updates it after that. The response says which happened:

```json
{ "data": { "id": "69a47995-bc6a-4bc9-9b9b-070935b974bb", "first_name": "Eva", "last_name": "Docs", "external_source": "bullhorn", "external_ref": "12345", "created": true, "…": "…" } }
```

The same call again returns `"created": false`. `source` matches `[a-z0-9_-]{1,40}` (case-insensitive). `ref` is up to 120 characters; URL-encode it. The body is the same full object as `POST` (camelCase). Omitted optional fields are set to null.


Batch variant: `POST /people/batch` (and `/clients/batch`, `/placements/batch`). Rows with `externalSource` + `externalRef` upsert by external ID.

## Client-generated claim IDs

`POST /claims` accepts an optional `id` (UUID v4). If a claim with that `id` already exists, the call returns the current day and inserts nothing. This works on all claim routes, including `POST /claims/batch` items.

```json
{ "id": "3b1f8e2a-6c1d-4c8e-9f7a-2d5e0b9c4a11", "placementId": "…", "workDate": "2026-12-10", "minutes": 450, "source": "import" }
```

## Recommended pattern

| Write | Do this |
|---|---|
| Master data from ATS/CRM | `PUT /…/by-external/{source}/{ref}` or the batch endpoint, plus `Idempotency-Key` on the batch |
| Hours from another system | `POST /claims/batch` with a deterministic `id` per row (e.g. UUIDv5 of `source:recordId:date`) and `sourceRef` |
| One-off writes from scripts or iPaaS | `Idempotency-Key: <uuid>`; keep the same key across retries |
| Export commit / lock | A human decides to commit or lock. Send an `Idempotency-Key` so a network retry of that one decision is safe |

The CLI (`tt api`) does not send an `Idempotency-Key`. Use curl or your own HTTP client for retry-safe scripts.
