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.
Last updated: · Markdown version
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
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/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/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:
{ "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.
{ "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.