hydrant implements a jetstream-compatible websocket subscription endpoint when compiled with the jetstream feature. it allows clients to consume atproto repository updates (commits, identity updates, and account state transitions) via a simplified, JSON-based format.
GET /subscribe
subscribe to the jetstream websocket stream.
query parameters
| param | type | description |
|---|---|---|
wantedCollections |
seq |
list of collection NSIDs to receive (e.g. app.bsky.feed.post). supports namespace wildcards (e.g. app.bsky.feed.*). |
wantedDids |
seq |
list of DIDs to receive (e.g. did:plc:abc123xyz). |
wantedEventTypes |
seq |
list of event types to receive: live or historical. if not specified, cursor replay returns both and the live tail only live (see live stream scope). |
maxMessageSizeBytes |
integer | filters out events whose serialized JSON size exceeds this value. |
cursor |
integer | unix microseconds timestamp (time_us) to replay historical events from. a cursor past the newest event starts at the live tail. |
compress |
boolean | if true (or if header Socket-Encoding contains zstd), compresses frames using zstd and sends them as binary websocket frames. |
requireHello |
boolean | if true, the socket will not stream any events until the client sends a hello / options_update message first. |
in-stream options update
clients can dynamically modify filtering criteria (wantedCollections, wantedDids, maxMessageSizeBytes, and wantedEventTypes) without reconnecting by sending a text frame with the following JSON format:
{
"type": "options_update",
"payload": {
"wantedCollections": ["app.bsky.feed.post", "app.bsky.like.*"],
"wantedDids": ["did:plc:abc123xyz"],
"maxMessageSizeBytes": 5000000,
"wantedEventTypes": ["live"]
}
}event payload formats
all jetstream events are serialized as JSON objects containing:
did: string (did of the repository)time_us: integer (unix microseconds timestamp)kind: string ("commit","identity", or"account")
commit
fired when a record is created, updated, or deleted:
{
"did": "did:plc:abc123xyz",
"time_us": 1716823456789012,
"kind": "commit",
"commit": {
"rev": "3kpjxabc123",
"operation": "create",
"collection": "app.bsky.feed.post",
"rkey": "3kpjxabc123",
"record": {
"text": "hello, world!",
"$type": "app.bsky.feed.post",
"createdAt": "2026-05-27T12:00:00.000Z"
},
"cid": "bafyreihy..."
}
}record keeps the keys in the order the record's CBOR has them, shorter keys first, rather than sorting them.
a redaction stops a body from being served once it completes, except in events already queued for a reader at that moment.
if hydrant is running in indexer mode with HYDRANT_ONLY_INDEX_LINKS=true, record content blocks are not persisted. consequently, record and cid may be omitted from commit payloads.
identity
fired when a did document or handle updates:
{
"did": "did:plc:abc123xyz",
"time_us": 1716823456789012,
"kind": "identity",
"identity": {
"did": "did:plc:abc123xyz",
"seq": 1234567,
"time": "2026-05-27T12:00:00Z",
"handle": "user.bsky.social"
}
}account
fired when a repository's status changes (active, deactivated, or deleted):
{
"did": "did:plc:abc123xyz",
"time_us": 1716823456789012,
"kind": "account",
"account": {
"active": false,
"did": "did:plc:abc123xyz",
"seq": 1234567,
"time": "2026-05-27T12:00:00Z",
"status": "deactivated"
}
}additional details
- live stream scope: by default, cursor replay returns both live firehose events (
live: true) and historical backfill / sync replay events (live: false), interleaved in commit order, while the live tail carries only live events, even after the subscriber falls behind and catches up from the database. filter them with thewantedEventTypesquery parameter. asking forhistoricalexplicitly also delivers backfills that finish while the socket is open, from the live tail. - ordering: events are delivered in commit order, which is also
time_usorder, so a client that reconnects with its lasttime_usas the cursor misses nothing the socket would have sent. - replay speed: a cursor replay runs at most
HYDRANT_STREAM_REPLAY_SPEEDtimes the bandwidth the live stream would take (10 by default), orHYDRANT_STREAM_REPLAY_FLOOR_BYTESa second (about 80 Mbit/s) when that's more. events your filters skip count a little too, since hydrant still reads them. the live tail never waits on it. a single trusted consumer that has to catch up fast (such as a service resuming from its saved cursor) can leave jetstream out ofHYDRANT_THROTTLED_STREAMSto replay uncapped. - falling behind: a subscriber that falls behind the live broadcast catches up from the database at its own pace instead of being disconnected.
- slow consumers: if a socket's send buffer remains full for longer than the configured timeout, the server sends a
{"type":"error","error":"ConsumerTooSlow"}message and drops the connection.