Skip to main content
A network timeout does not tell you whether the write landed. smry gives you three different ways to retry a write safely, and they work differently. Pick the one that matches the operation you are calling. Everything else — PATCH, DELETE, and every read — is naturally idempotent. Retry those freely under the policy in Errors.

Saves: the natural key

POST /v1/library/items deduplicates on two fields. Send either and a retry is safe:
  • url — normalized, then matched against the account’s existing items.
  • externalId — your own identifier, matched exactly. Use this when the same logical document can arrive under different URLs.
The response tells you which happened:
A save with neither url nor externalId has no natural key. Every retry creates another item. Always set externalId when you are saving supplied content rather than a URL.

Rule

Every POST /v1/library/items from a retryable pipeline MUST carry a stable externalId derived from your own source record. Do not generate it per attempt.
200 OK — the retry

Mutations: the commit token

POST /v1/feeds/mutate is a two-phase write. Phase one previews. Phase two commits a token. The rules:
  • dry_run defaults to true. A call without it changes nothing and hands back a commit_token.
  • A commit_token executes exactly once. Replaying it returns the recorded outcome, never a second execution.
  • Tokens live 15 minutes. After that: invalid_request — “commit_token is unknown or expired (15 minute lifetime). Re-run the dry run.”
  • A token is bound to its op. Sending it with a different op is rejected.
  • A concurrent replay is a 409 commit_in_progress, and it is retryable. One caller wins the claim and executes; the others are told a commit is in flight.
If commit_in_progress never resolves into a recorded outcome, treat the commit as possibly partial. Query current state before running a fresh dry run. Do not assume nothing happened.

Rule

Retry a commit_token with the identical op. Never mint a new token to retry a commit that may already have run.
200 OK — dry run
sample is up to ten labels, not objects. There is no dry_run field in the response — the preview is what object: "feed_mutation_preview" tells you.
POST /v1/feeds/filters/apply uses a related but stricter protocol. Its previewToken is paired with an expectedRevision, and the apply fails with 409 revision_conflict if the Collection changed in between. That is optimistic concurrency, not idempotency: on a conflict you must preview again.

Consumers: at-least-once delivery

A managed consumer holds its checkpoint on the server, so a stateless scheduled job needs no local cursor file. The cost is that delivery is at-least-once.
A batch is redelivered until it is acknowledged. Crash after processing but before the ack and you will see the same events again. Your handler MUST be idempotent.
The loop is: poll, process everything, then acknowledge — in that order.
  1. POST /v1/feeds/changes with consumer_id. Omit cursor and where — the consumer holds both.
  2. Process every event in events.
  3. POST /v1/feeds/consumers/{id}/ack with that response’s next_cursor.
Acknowledge an empty batch too. checkpoint.ack_required tells you when an ack is expected.

Rule

Make the handler idempotent, keyed on event.entry.id. Acknowledge only after every event in the batch is durably processed.

Creating a consumer is itself idempotent

Retrying POST /v1/feeds/consumers with the same name and the same where returns the existing consumer. Names are compared case-insensitively and the predicate is fingerprinted. The same name with a different predicate is a 409 consumer_name_taken. That is deliberate: silently rebinding a name to a new predicate would move a live checkpoint onto a stream it was never reading.
200 OK — the consumer, created or returned
smry canonicalizes the predicate before storing it: collection and source names are resolved to their ids. A consumer created with {"collection": ["Research"]} echoes {"collection": [12]}. Store what the response returned.
A new consumer starts at the current high-water mark, so it receives future discoveries rather than replaying history. To also process the backlog: create the consumer first, query the historical window separately with POST /v1/feeds/query, then start polling. That order closes the race.
Acknowledgements only move forward. A cursor behind the current checkpoint, from another consumer, or from a poll that was superseded is rejected — see invalid_request in Errors.

Next

Cursors and paging

The four cursor dialects, and why a cursor must never be reused across a changed predicate.

Errors

commit_in_progress, consumer_name_taken, and every other code a retry can produce.
Last modified on September 13, 2026