Steerd API

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 }.

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 answers 200 with 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_logged and 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 PATCH ignores 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, 201 included.

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.

On this page