Skip to main content

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.

EndpointReturns
GET /event-feedthe next items after a cursor, with an optional long-poll
GET /event-feed/statsper-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-feed in it.
  • The project must register event-feed first (PUT /v1/purposes). A key update that names a purpose the project has not registered is 400, 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_cursor and high_watermark_position, the newest item;
  • retention_floor_cursor and retention_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 readEventFeed admits for you: every project you and your organization created, in your registered environments, until a project's purge completes.
  • Your known map. Keep the (feed_epoch, high_watermark_position) you last saw for each feed and send it as known. 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 readEventFeed from your own cursor until caught_up, then merge the head into known.
  • 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_purged item.
  • Pruning. Drop known entries of projects that listApplicationProjects no 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 with heads_scope_too_large and names a shard_count that is enough: call once per index from 0 to shard_count - 1. Eight shards cover at most 8,000 feeds. Past that, the error names no shard_count; watch those feeds with readEventFeed instead.
    • The request body is at most 128 KiB.
  • Repeating. A watch is safe to repeat, never cached, and not metered: the readEventFeed calls 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 event item can later read as event_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 on item_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 GET and are valid for 15 minutes.
  • resume_cursor is 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 on item_identity.
  • The deletions object 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 new revision without the entity is available. A part that cannot be rewritten in time becomes withdrawn; take a new export.
  • At most 2 exports may be queued or running per project and environment; a third request gets 429 resource_exhausted with retry_after_seconds (60). An export reads only in the environment of the credential that created it.

Expired cursors and resets​

  • 400 failed_precondition with reason cursor_expired means your cursor is older than the feed's retention (30 days).
  • Reason feed_reset means the feed started a new epoch.

Both details carry the current epoch's floor and high-watermark cursors. To recover:

  1. call createEventExport;
  2. erase every entity_hash in its deletions object from your copy;
  3. merge its lines, deduplicating on item_identity;
  4. 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_snapshot on POST /sources/{source_id}/mapping:preview and catalog-mapping:preview);
  • a projection export (POST /slots/{slot}/exports with kind: 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.examples and profile.top_values;
  • GET /uploads/{upload_id} returns its columns and sample_rows preview as null;
  • GET /syncs/{sync_id} and GET /sources/{source_id}/syncs drop the position (the key-column values of the last row read) from a failed page's keyset_position error detail;
  • the trajectory inspector returns empty approved_original_fields and original_source_events;
  • GET /slots/{slot}/exports/{export_id} returns a succeeded projection colored by event attributes without its artifact_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.