Committed event feed
The event feed is an ordered stream of every event committed to a project's
canonical event store, from every intake path: the ingestion API, SaaS
adapters, dead-letter replays, warehouse syncs and file uploads.
Read it with a cursor to keep an exact copy of a project's events without
knowing entity ids in advance. Each project environment (live, test) has its
own feed.
| Endpoint | Returns |
|---|---|
GET /event-feed | the next items after a cursor, with an optional long-poll |
GET /event-feed/stats | per-source hourly counts of appended events |
POST /event-exports, GET /event-exports/{export_id} | an export of the full retained history |
A key needs the read scope; a console session needs a role that may read
the feed. Every endpoint here, and the connected-application heads watch, is
bound to the fixed purpose event-feed:
- A credential with a purpose allow-list (an API key with one, or a console
session whose custom role has one) also needs
event-feedin it. - The project must register
event-feedfirst (PUT /v1/purposes). A key update that names a purpose the project has not registered is400, and a custom role's name that the project has not registered allows nothing there. - A credential without an allow-list needs neither.
A refused call is 403 permission_denied with details[0].reason
purpose_not_allowed and details[0].purpose event-feed. A long-poll
takes the decision again when it wakes, so a key narrowed while it waited
gets the refusal, not the page.
Reading the feed
curl -H "Authorization: Bearer $TESSERA_KEY" \
"https://api.staging.tessera.zone/v1/event-feed?start=oldest&limit=200"
Each page carries:
data, the items in position order;next_cursor, where to read from next (present on every page);high_watermark_cursorandhigh_watermark_position, the newest item;retention_floor_cursorandretention_floor_position, the oldest retained item;caught_up, whether anything newer existed when the page was read;feed_epoch.
Pass after=<cursor> to continue, or start=now to receive only new items.
limit is 1-500, and a page also stops at 4 MiB. When you are caught up, set
wait_seconds (up to 20) and the read waits for the next item before
answering.
Cursors are opaque and work only with the credential's project and environment.
Watching many feeds at once (applications)
A connected application that created several projects can avoid keeping one idle long-poll per feed. It parks a single heads watch, which answers when any feed it may read moves:
curl -X POST -H "Authorization: Bearer $APPLICATION_ID_TOKEN" -H "Content-Type: application/json" -d '{"known": [], "wait_seconds": 20}' "https://api.staging.tessera.zone/v1/event-feed/heads:watch"
The answer lists positions only, as {project_id, environment, feed_epoch, high_watermark_position} per feed.
- Coverage. It covers exactly the feeds
readEventFeedadmits for you: every project you and your organization created, in your registered environments, until a project's purge completes. - Your
knownmap. Keep the (feed_epoch,high_watermark_position) you last saw for each feed and send it asknown. The watch returns only the feeds that differ and the ones you do not know yet. - Reading. For each returned head, read that feed with
readEventFeedfrom your own cursor untilcaught_up, then merge the head intoknown. - Pages. At most 500 heads come per page. Follow
next_page_token(it is answered at once) until it is absent. A page token is good for 60 seconds; past that, start again from the first page. A head you have not merged stays due, so no move is ever lost between calls. - Purges. A purge arrives as one ordinary move, to the position of the
feed's terminal
project_purgeditem. - Pruning. Drop
knownentries of projects thatlistApplicationProjectsno longer lists; the watch ignores them anyway. - Limits.
- You may park 2 watches per serving replica. Past that the watch answers
at once with
Retry-After: 1; call again after it. - A watch covers at most 1,000 feeds. Split more with
shard: {"count": n, "index": i}. A watch over the limit fails withheads_scope_too_largeand names ashard_countthat is enough: call once per index from 0 toshard_count - 1. Eight shards cover at most 8,000 feeds. Past that, the error names noshard_count; watch those feeds withreadEventFeedinstead. - The request body is at most 128 KiB.
- You may park 2 watches per serving replica. Past that the watch answers
at once with
- Repeating. A watch is safe to repeat, never cached, and not metered:
the
readEventFeedcalls it leads to are.
Ordering and guarantees
-
Positions are dense within a
feed_epoch. Once a position is readable, every lower retained position is readable too. -
An item is appended only after its event is committed, so its payload is final.
-
Items never move. An
eventitem can later read asevent_redacted(see below); nothing else ever changes. -
Each committed event appears once, identified by
item_identity. Duplicates can still reach you in three cases, so deduplicate onitem_identity:- export and feed overlap;
- re-commits of events from before the feed was activated;
- re-commits after an identity has expired.
The first commit of an identity wins.
-
Realtime events reach the feed about 25 seconds (p95) after
POST /events. Batch syncs and uploads reach it right after their commit.
Reconciling with stats
GET /event-feed/stats returns, per source_id and UTC hour of
appended_at, how many items were appended as events (events) and how
many of them are now stored redacted (redacted_events). since is
inclusive and until exclusive; both are truncated to the hour and may be at
most 744 hours apart. Without them the window is the 24 hours before the
read, through the end of the current hour. Pages hold 50 rows by default
(limit up to 200).
Every hour ending at or before sealed_through is final: no later append can
land in it. Compare those hours with the items you received to detect a gap.
Erasure
When a data-subject deletion is accepted (POST /entities/{type}/{id}:delete),
both environments' feeds append an entity_deleted item. It carries the raw
entity: {type, id}, entity_hash and deletion_id. From that moment, every
event of that entity is served as event_redacted, carrying only its position,
source_id and deletion_id. A redacted item can appear before the
entity_deleted item it refers to.
Every unredacted event item carries entity_hash. Store it with your copy:
when the deletion completes, the raw id in entity_deleted reads null, and
entity_deletion_completed identifies the entity only by entity_hash. The
deletion does not complete before its entity_deleted items have been in the
feed for at least one hour, so a consumer that keeps up can act on the raw id.
Exporting the full history
POST /event-exports takes optional filters (event_time_from/to,
received_from/to, event_types, source_ids) and expires_in_hours
(1-168, default 168), and answers 202 with a queued export. Poll
GET /event-exports/{export_id} until status is succeeded.
- Each part is gzipped NDJSON; every line is
{item_identity, entity_hash, event, envelope}. - Download URLs are minted on each
GETand are valid for 15 minutes. resume_cursoris the feed position the export covers: read the feed after it to continue without loss. Some events can appear in both the export and the feed; deduplicate onitem_identity.- The
deletionsobject lists every data-subject deletion of the project up to the export, as{deletion_id, entity_hash, requested_at, completed_at}. Erase those hashes from your copy. - When a deletion is accepted for an entity in a part, the part reads
rewriting(no URL) until a newrevisionwithout the entity is available. A part that cannot be rewritten in time becomeswithdrawn; take a new export. - At most 2 exports may be queued or running per project and environment;
a third request gets
429 resource_exhaustedwithretry_after_seconds(60). An export reads only in the environment of the credential that created it.
Expired cursors and resets
400 failed_preconditionwith reasoncursor_expiredmeans your cursor is older than the feed's retention (30 days).- Reason
feed_resetmeans the feed started a new epoch.
Both details carry the current epoch's floor and high-watermark cursors. To recover:
- call
createEventExport; - erase every
entity_hashin itsdeletionsobject from your copy; - merge its lines, deduplicating on
item_identity; - resume the feed from its
resume_cursor.
Losing the event-feed purpose
For a feed consumer, 403 permission_denied with details[0].reason
purpose_not_allowed on a feed read means your credential lost its
event-feed binding. Handle it like a 401: you no
longer receive entity_deleted items or an export's deletions object, so
erase your copy or stop treating it as current. An export you created runs
for your credential and checks it again before it starts; if the check fails,
the export fails with error.code purpose_not_allowed and writes nothing.
A retry of POST /event-exports with the same Idempotency-Key replays the
stored 202 from the response cache without checking again, as for the other
purpose-checked POST operations. That response carries no download URL and
creates nothing; GET /event-exports/{export_id} checks the purpose on every
read before it mints a URL.
Other reads of raw events
The same event-feed purpose, with the same rules and the same refusal
(a list that names event-feed allows nothing until the project registers
it), also covers the other reads that return raw event payloads:
- the entity timeline (
GET /entities/{entity_type}/{entity_id}/events) and the evidence timeline (GET /slots/{slot}/entities/{entity_type}/{entity_id}/evidence-timeline), whose timeline is that same page; the evidence timeline also needs its slot's purpose; - dead-letter items (
GET /events/deadletter); - the pre-ingest source sample (
POST /sources/{source_id}/sample), a sync's rejected rows (GET /syncs/{sync_id}/rejections), and mapping previews that sample the latest landed snapshot (from_snapshotonPOST /sources/{source_id}/mapping:previewandcatalog-mapping:preview); - a projection export (
POST /slots/{slot}/exportswithkind: projection_2d) that colors points by event attributes, since each color is an entity's newest raw event value; it also needs the slot's purpose, and the run checks the requester again.
Some reads stay available without event-feed and leave out only their raw
values:
- contract reads return empty
profile.examplesandprofile.top_values; GET /uploads/{upload_id}returns itscolumnsandsample_rowspreview asnull;GET /syncs/{sync_id}andGET /sources/{source_id}/syncsdrop theposition(the key-column values of the last row read) from a failed page'skeyset_positionerror detail;- the trajectory inspector returns empty
approved_original_fieldsandoriginal_source_events; GET /slots/{slot}/exports/{export_id}returns a succeeded projection colored by event attributes without itsartifact_url.
For every caller, a failed sync's error message never quotes the text of a
connector, parser or driver error, which can contain a source value. It says
what failed, the error's class, the row, line or column it named and the
expected type, for example
connector extract failed (ArrowInvalid, column 3, row 48213, expected int64).
A credential without an allow-list, a built-in role and an application are
unaffected. Reads that carry only ids, timestamps or counts need no purpose:
the entity lookup (GET /entities) and presence (GET /entities/presence),
ingestion receipts, sync diagnostics and processing counts, and semantic
exceptions, whose evidence is metadata only. Neither do mapping previews of
rows you send in the request (sample_payload), which return only your own
rows, mapped. Feature views check their slot's purpose.
Project deletion
When a project is deleted, its API keys are revoked at once, and a key-based
reader gets 401 unauthenticated. Treat that as the loss of your binding.
A connected application that created the binding keeps read access: from the
start of the destructive purge it reads a single terminal project_purged
item, and after the purge completes it gets 404.