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.
Rule
EveryPOST /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_rundefaults totrue. A call without it changes nothing and hands back acommit_token.- A
commit_tokenexecutes 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 differentopis rejected. - A concurrent replay is a
409commit_in_progress, and it is retryable. One caller wins the claim and executes; the others are told a commit is in flight.
Rule
Retry acommit_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. The loop is: poll, process everything, then acknowledge — in that order.POST /v1/feeds/changeswithconsumer_id. Omitcursorandwhere— the consumer holds both.- Process every event in
events. POST /v1/feeds/consumers/{id}/ackwith that response’snext_cursor.
checkpoint.ack_required tells you when an ack is expected.
Rule
Make the handler idempotent, keyed onevent.entry.id. Acknowledge only after every event in the batch is durably processed.
Creating a consumer is itself idempotent
RetryingPOST /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
{"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.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.