Idempotency
Make writes safe to retry with an Idempotency-Key.
Unsafe writes (POST, PATCH, DELETE) honor an Idempotency-Key request header,
so a retried request runs the operation at most once.
curl -X POST https://app.steerd.io/api/public/v1/contacts \
-H "Authorization: Bearer strd_YOUR_KEY" \
-H "Idempotency-Key: 9f1c2b7e-4a6d-4c2e-8b1a-3d5e7f9a0c11" \
-H "Content-Type: application/json" \
-d '{ "firstName": "Jane", "lastName": "Doe", "email": "jane@example.com" }'Behavior
- Repeating a request with the same key and the same body replays the original response. The operation runs once.
- Reusing a key with a different body returns
422 idempotency_key_conflict. - A key whose original request is still in flight returns
409 conflict; retry shortly. - A replay whose caller's permissions have narrowed since the original request returns
409 idempotency_authorization_changed. Retry without the key, or with a new one, to get a correctly-authorized answer. Gaining a permission never affects a replay. - Keys are retained for 24 hours, scoped to
{ key, method, path }.
Recommended usage
Generate a fresh UUID per logical write, and reuse the same value when retrying after a network failure. That way a request that succeeded on the server but whose response you never received will not create a duplicate on retry.
Idempotency covers the transport-level retry problem; a lost response, a dropped connection. It is scoped per method and path, so the same UUID on a different endpoint is a different logical operation.
Time entries: name the work with external_ref
An Idempotency-Key covers the retries of one request, for 24 hours, on one API key. An
agent that logs the same work again days later, from another run or with another key, needs
a name that lasts as long as the entry does. That is external_ref.
curl -X POST https://app.steerd.io/api/public/v1/time-entries \
-H "Authorization: Bearer strd_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "entry_date": "2026-10-20", "mode": "total", "duration_input": "1:30", "external_ref": "gcal:4f9a2c:9b1deb4d" }'- The first create answers
201. A later create with the same ref anywhere in your team writes nothing and answers200with the stored entry, so its date, hours and project are the stored ones, not the ones you sent. - A caller who may not read the stored entry gets
409 already_loggedand nothing else. - The ref is unique in your team, at most 200 characters, trimmed, and free of control
characters. An update never changes it: a
PATCHignores the field. Deleting the entry frees it, and the same ref then logs a new entry. - Derive it from the source, one per entry: a calendar event plus the employee the time is
logged for, as in
gcal:<event id>:<employee id>. Never a random value, which a retry cannot repeat, and never a date plus a project, which merges two pieces of work on one day. - Sent together with an
Idempotency-Key, a retried key still replays its first response verbatim,201included.
GET /time-entries?external_ref=... finds the entry. Over GraphQL the field and the filter
are externalRef, a repeated create returns the stored entry, the refusal carries
already_logged in errors[].extensions.code, and UpdateTimeEntryInput has no
externalRef. Over MCP, create_time_entry takes the same field, says which
case happened in already_logged, and refuses with a tool error whose code is
already_logged.
File uploads are covered too. POST /files and POST /attachments honor
Idempotency-Key like every other write. Because the body of a multipart request is not the
file, what the key is matched against is a digest of the uploaded bytes plus the filename, type
and size (and, for an attachment, the entity it is bound to). So a retry with the same key and
the same file replays the original response, and the same key with a different file returns
422 idempotency_key_conflict rather than quietly handing back the first file's response.