Streaming Semantics
This page defines the exact behavior of the watch and replay endpoints, including start points, spatial filtering, identifier constraints, and SSE lifecycle events.
Request ID Correlation
Every HTTP response carries an X-Request-ID header with a per-request UUID.
The same UUID is also embedded in the JSON data: payload of certain SSE events
so that a client which only sees the body (not headers) can still quote it back
when reporting a problem.
A note on terminology before the tables: SSE has two related but distinct labels
for an event. The event: line is the SSE-level type that an
EventSource.addEventListener(name, handler) call dispatches on. The
data.type field (when present) is an aviso-level discriminator inside the JSON
body, used for the cases where we reuse a single event: name for several
control events. A wire example:
event: live-notification
data: {"type":"connection_established","topic":"...","timestamp":"...","connection_will_close_in_seconds":3600,"request_id":"<uuid>"}
The event: line above is live-notification (so an EventSource client listens
for live-notification); the data.type value is connection_established (so
the client distinguishes it from a normal notification, whose type is the
CloudEvent type int.ecmwf.aviso.<event_type>).
In the tables below, Event is the SSE event: line and Type is the
data.type field.
In-stream events that include request_id:
| Event | Type | Sent |
|---|---|---|
live-notification | connection_established | First event of a live-only watch |
replay-control | replay_started | First event of a stream that begins with replay |
error | none; the error field identifies it | On a backend or CloudEvent-creation failure mid-stream |
connection-closing | none; the reason field identifies it | Last event, on a graceful close |
In-stream events that intentionally do not include request_id:
| Event | Type | Sent |
|---|---|---|
live-notification | CloudEvent type | Every live notification |
replay | CloudEvent type | Every replayed notification |
heartbeat | none | Every few seconds |
replay-control | replay_completed, notification_replay_limit_reached | At replay phase boundaries |
The response header and the first event already carry the UUID, so repeating it would add bytes without adding information.
The first event of any stream is guaranteed to carry the request_id (a
live-notification event with data.type = "connection_established" for
live-only watches, or a replay-control event with
data.type = "replay_started" for any stream that begins with replay).
Historical Replay Limits
watch_endpoint.max_historical_notifications limits historical notifications
delivered by one request, not one backend batch. Identifier matching,
identifier constraints, spatial filtering and successful CloudEvent rendering
happen before quota accounting. Render failures emit errors without consuming
the quota. A schema’s max_historical_notifications overrides the global cap;
omitting it inherits the global default of 10000.
replay_batch_size controls fetching independently of this quota. Excluded
notifications do not consume it, even when entire batches are excluded.
After filling the quota, replay looks for one more renderable matching
notification. If history is exhausted first, replay completes normally.
Otherwise the server emits a replay-control event with these data fields:
{"type":"notification_replay_limit_reached","topic":"example.*","max_allowed":2,"message":"Historical replay limited to 2 messages. Additional historical messages may be available but were not retrieved.","timestamp":"2026-01-01T00:00:00Z"}
The limit control is followed by connection-closing with reason
end_of_stream. There is no replay_completed event and a watch does not
transition to live delivery. Treat this as a history gap, not successful
catch-up. max_allowed is always present and reports the effective request
cap, including a schema override. A new request gets a fresh quota.
Caps must be positive integers. Zero and unlimited are not accepted.
If fetching a historical batch fails, including during lookahead, the server
emits event: error and closes without replay_completed, a limit control or
live delivery. This is failed catch-up, not exhausted history. Individual
CloudEvent rendering failures remain nonfatal and do not consume the quota.
Live-only watches are not limited.
Replay/Live Boundary
A watch captures an inclusive history sequence bound H atomically with live
subscription creation. Replay reads only sequences <= H, then emits
replay_completed before delivering live sequences > H. Publications during
catch-up belong to live delivery, not to later historical batches. No
last-delivered watermark is used to discard queued live notifications.
A replay-only request captures H during setup without creating a live
subscription. Its sequence range stays fixed across every page, including quota
lookahead. Messages above H cannot consume the replay quota or prove
truncation. A deleted or filtered message at H does not prevent completion.
An end point (to_id or to_date) lowers H for that request. See
End Point for Historical Events.
This snapshots a sequence range, not immutable storage. Deletion, overwrite and retention can remove history before it is read. In-memory live queues can lag; JetStream retention can remove queued live messages before delivery. The boundary prevents replay/live overlap, but does not guarantee lossless delivery.
Sequence and date start cursors constrain history only. A start beyond H (or
a future date) can produce empty replay, after which a watch still delivers new
notifications from its subscription creation point. Setup failures return an
HTTP error, not a successful replay completion.
Reconnecting after disconnect
If a stream drops (network blip, client restart, connection-closing with
reason max_duration_reached, etc.), the recommended reconnect protocol is:
- Read the top-level CloudEvent
idfrom each notification’s SSEdata:body. It has the form<event_type>@<sequence>, such asextreme_event@123. The numeric suffix is the sequence; normal notifications do not have a separatesequencefield. Control events, includingconnection_established, are not notification checkpoints. - Save progress only after successfully processing the notification. If processing concurrently, do not advance the checkpoint past unfinished notifications.
- Issue a fresh
POST /api/v1/watch(or/api/v1/replay) with the same event type and filters. Setfrom_idto the saved sequence plus 1, encoded as a decimal JSON string, and omitfrom_date. The client computes this increment; the server treatsfrom_idas inclusive.
For example, after processing and saving a checkpoint for extreme_event@123,
send a new POST /api/v1/watch request with this JSON body, keeping the same
identifier filters as the original request:
{
"event_type": "extreme_event",
"identifier": {},
"from_id": "124"
}
Here, identifier: {} represents a request without identifier filters. Replace
it with your original filters; schemas with required filters do not accept an
empty identifier object.
This avoids requesting the checkpoint notification again, but does not guarantee duplicate-free processing. A crash after applying an effect but before saving progress can cause that notification to be processed again. Use idempotent handlers or deduplicate notifications within the same backend history.
Recovery depends on the requested history still being available. Retention or deletion can remove notifications, and in-memory history is node-local and disappears on restart. A saved cursor is not portable across unrelated or reset backend histories. Reconnecting cannot recover history that is no longer stored and does not guarantee lossless delivery. Each reconnect is still subject to the historical replay limits above.
If you need time-based catch-up rather than sequence-based, use from_date
instead of from_id (see
Start Point for Historical Events below
for accepted formats).
SSE Stream Lifecycle
Every streaming response (watch or replay) goes through a typed lifecycle:
stateDiagram-v2
[*] --> Connected : SSE connection established
Connected --> Replaying : from_id or from_date provided
Connected --> Live : no replay parameters (watch only)
Replaying --> Live : replay_completed (watch only)
Replaying --> Closed : end_of_stream (replay only)
Live --> Closed : max_duration_reached
Live --> Closed : server_shutdown
Closed --> [*]
Close reasons emitted in the final connection-closing SSE event:
| Reason | Trigger |
|---|---|
end_of_stream | Replay finished, was truncated, or failed during batch retrieval |
max_duration_reached | connection_max_duration_sec elapsed on a watch stream |
server_shutdown | Server is shutting down gracefully |
POST /api/v1/watch
- If both
from_idandfrom_dateare omitted:- stream is live-only (new notifications from now onward).
- If exactly one replay parameter is present:
- historical replay starts first, then transitions to live stream.
- If both are present:
- request is rejected with
400.
- request is rejected with
to_idandto_dateare not accepted; they are rejected with400.
flowchart TD
A["Watch Request"] --> B{"from_id or<br/>from_date?"}
B -->|neither| C["Live-only stream"]
B -->|exactly one| D["Historical replay<br/>then live stream"]
B -->|both| E["400 Bad Request"]
style E fill:#8b1a1a,color:#fff
style C fill:#1a6b3a,color:#fff
style D fill:#1a4d6b,color:#fff
POST /api/v1/replay
- Requires exactly one replay start parameter:
from_id(sequence-based), orfrom_date(time-based).
- If both are missing or both are present:
- request is rejected with
400.
- request is rejected with
- Accepts at most one replay end parameter:
to_id(sequence-based), orto_date(time-based).
- Stream closes with
end_of_streamwhen history is exhausted or the end point is reached.
Start Point for Historical Events
from_id is an unsigned 64-bit sequence number encoded as a JSON string.
Historical delivery starts at that number (inclusive), subject to the request
filters and available history. Send a decimal string, not a JSON number or the
full CloudEvent id. The value "0" starts from the beginning of available
matching history.
from_date accepts any of these formats:
| Format | Example |
|---|---|
| RFC3339 with timezone | 2025-01-15T10:00:00Z |
| RFC3339 with offset | 2025-01-15T10:00:00+02:00 |
| Space-separated with timezone | 2025-01-15 10:00:00+00:00 |
| Naive datetime (interpreted as UTC) | 2025-01-15T10:00:00 |
| Unix seconds (≤ 11 digits) | 1740509903 |
| Unix milliseconds (≥ 12 digits) | 1740509903710 |
All inputs are normalized to UTC internally.
End Point for Historical Events
A replay can stop before the last stored notification. to_id and to_date
mirror from_id and from_date, and are accepted only by
POST /api/v1/replay.
to_id is an unsigned 64-bit sequence number encoded as a JSON string. Delivery
ends at that number (inclusive). Unlike from_id, the value "0" has no
special meaning: sequences start at 1, so it produces an empty replay.
to_date accepts the same formats as from_date and ends delivery with the
last notification stored at or before that time (inclusive). Like from_date,
it refers to the time the server stored the notification, which is the
CloudEvent time.
The replay ends at whichever comes first: the end point, or the last notification stored when the replay starts. An end point in the future therefore ends at the last stored notification, and an end point before every stored notification produces an empty replay that completes normally.
The end point bounds history before filtering, like H. The replay limit
counts only notifications inside it, so a window with no more notifications
than the limit is never truncated.
Requests are rejected with 400 when:
- both
to_idandto_dateare present; to_idis lower thanfrom_id, orto_dateis earlier thanfrom_date.
A sequence end may follow a date start, and a date end may follow a sequence start. These are not compared, and may produce an empty replay.
The replay_started event reports the end point. end_sequence is the last
sequence the replay can deliver, which may be lower than the requested to_id,
and to_date repeats the requested time (truncated to whole seconds). Both are
null without an end point.
Spatial Filter Model
Spatial filtering applies on top of the identifier field filters. Think of it in two layers:
- Non-spatial identifier fields (
time,date,class, etc.) narrow candidates by topic routing. - Spatial fields (
polygonorpoint) further narrow that candidate set geographically.
flowchart LR
A["Candidate<br/>notifications"] -->|"non-spatial<br/>identifier filter"| B["Topic-matched<br/>subset"]
B -->|"spatial filter<br/>if provided"| C["Final<br/>results"]
Rules
identifier.polygon | identifier.point | Result |
|---|---|---|
| provided | omitted | polygon-intersects-polygon filter |
| omitted | provided | point-inside-notification-polygon filter |
| omitted | omitted | no spatial filter |
| provided | provided | 400 Bad Request |
identifier.polygon: keep notifications whose stored polygon intersects the request polygon.identifier.point: keep notifications whose stored polygon contains the request point.- Both together: invalid (the request is rejected).
Point-cloud schemas use a different provider and subscriber pair. Providers send
identifier.point_cloud on /notification. Subscribers send
identifier.polygon on /watch or /replay. A notification matches when any
cloud point is inside the request polygon or on an edge or vertex. Aviso checks
the stored bounding box first, then stops at the first matching point.
The request polygon satisfies a required point_cloud field for watch and
replay. Subscribers cannot send point_cloud. See
Point-Cloud Filtering for the
schema and request contract.
Identifier Constraints (watch / replay)
For schema-backed event types, identifier fields in watch/replay requests accept
constraint objects instead of (or in addition to) scalar values. A scalar
value is treated as an implicit eq constraint.
For schema-backed event types, unknown identifier names return HTTP 400 rather
than being ignored. The supported spatial filters (point for polygon schemas
and polygon for point-cloud schemas) remain valid.
Constraint objects are rejected on /notification: publish concrete
identifier values in the shapes accepted by their handlers, including arrays
for spatial identifiers, rather than watch/replay predicates.
Supported operators by field type
| Handler | Operators |
|---|---|
IntHandler | eq, in, gt, gte, lt, lte, between |
FloatHandler | eq, in, gt, gte, lt, lte, between |
EnumHandler | eq, in |
Notes
betweenexpects exactly two values[min, max]and is inclusive on both ends.- Float constraints reject
NaNandinf; only finite values are valid. - Float
eqandinuse exact numeric equality; no tolerance window is applied. - A constraint object must contain exactly one operator; combining operators in a single object is rejected.
Examples
{ "severity": { "gte": 5 } }
{ "severity": { "between": [3, 7] } }
{ "region": { "in": ["north", "south"] } }
{ "anomaly": { "lt": 50.0 } }
Backend Behavior
| Backend | Historical replay | Live watch |
|---|---|---|
in_memory | Node-local only; clears on restart | Node-local fan-out |
jetstream | Durable; survives restarts | Cluster-wide fan-out |
SSE Timestamp Format
All control, heartbeat, and close event timestamps use canonical UTC second precision:
YYYY-MM-DDTHH:MM:SSZ
Example: 2026-02-25T18:58:23Z
Replay Payload Shape
- Replay and watch CloudEvent output always includes
data.payload. - If a notify request omitted payload (optional schema), replay returns
data.payload = null. - Payload values are not reshaped; scalar strings remain strings, objects remain objects.
See Payload Contract for the full input → storage → output mapping.
For end-to-end examples, see: