InfiniteOceanv2

Realtime — listen, broadcast, and webhooks

Read a route as a live stream over long-poll or Server-Sent Events, and register webhooks that POST to your own endpoint when a route is written.

What the realtime doors do

A route in InfiniteOcean holds one record per key; writing the same key again replaces the previous value. The realtime doors let you read a route as a stream of changes rather than re-reading the whole thing: GET /v2/listen/{route} returns the records newer than a cursor you supply, either as one JSON response (polling) or over a held Server-Sent Events connection. POST /v2/hooks pushes the other way — InfiniteOcean POSTs to a URL you own whenever the route is written.

Two assumptions that people usually bring to a page like this, and that are wrong here:

Before you start

Every realtime door requires X-Ocean-Key. Set it once in your shell:

export OCEAN_KEY='<your-key>'
export API=https://api2.infiniteocean.io

A route only exists once something has been written to it, and access is granted by that first write. Listening to a route that was never written returns 403, not 404 or an empty list:

curl -s -H "X-Ocean-Key: $OCEAN_KEY" \
  "$API/v2/listen/_docs_probe/no-such-route-xyz"
{"ok":false,"error":"forbidden","code":"FORBIDDEN"}

Writing the first drop creates the rule (the response carries "rule_created":true). Every example below uses probe routes under _docs_probe/ and docsprobe/; substitute your own.

GET /v2/listen/{route} — polling

Returns the records on the route whose timestamp is after your cursor, oldest first. This is the default; you get SSE only if you ask for it.

curl -s -X POST "$API/v2/drop" \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"route":"_docs_probe/rtv1787593453","key":"evt-1","payload":{"kind":"temperature","celsius":21.5}}'

curl -s -H "X-Ocean-Key: $OCEAN_KEY" \
  "$API/v2/listen/_docs_probe/rtv1787593453?since_ts=0&limit=10"

The write returns:

{"ok":true,"seq":1787577342969366,"offset":0,"len":113,"ts":1787593457966,
 "state":"accepted","rule_created":true,"drop_id":"1787577342969366",
 "route":"_docs_probe/rtv1787593453","key":"evt-1"}

The listen, issued seconds after the write, returns:

{"ok":true,"route":"_docs_probe/rtv1787593453","kind":"listen","mode":"poll",
 "events":[{"key":"evt-1","ts":1787593457966,"value":{"kind":"temperature","celsius":21.5}}],
 "returned":1,"next_since_ts":1787593457966,"complete":false,"next_after_key":null}

Note "complete":false on a route that plainly holds one record and returned it. That is not a bug and it is not meaningless — see what complete means below.

Query parameters

Validation is strict — any parameter not in this list returns 400 UNKNOWN_PARAM and nothing is applied. The full accepted set is since_ts, limit, after_key, timeout_ms, mode, last, from.

ParameterMeaning
since_tsEpoch milliseconds. Returns records with ts strictly greater. Default 0 (everything). A non-integer returns 400 INVALID_INPUT.
limitMaximum events in the response. Default 50 — measured on a route holding 121 records, which returned exactly 50 with no limit. limit=0 returns 400. limit=1000 returns 200, not an error.
after_keyPaging companion to since_ts — see below. A 513-character value returns 400 after_key too long.
timeout_msLong-poll: hold the request open until something arrives. The hold is capped far below what you can ask. On an idle route, asks of 1 s, 3 s, 5 s, 10 s, 20 s and 60 s all returned in 2.7–4.0 s wall time. A non-integer returns 400.
modepoll (default) or sse. An unrecognised value is not rejected — mode=typo returns 200 with "mode":"poll". A typo here silently gives you polling instead of a stream.
from0, latest, an ISO-8601 timestamp, or epoch ms — an alternative way to set the same cursor since_ts sets. from=latest parks the cursor at now, so you get an empty page and a next_since_ts to continue from. Anything else returns 400.
lastA bounded window of N records. Required on wildcard routes; on an exact route it is an alias for limit — read the note below.

Response fields

FieldMeaning
events[]Ascending by (ts, key). Each event is {"key","ts","value"}. value is the drop payload verbatim.
returnedNumber of events in this page.
next_since_tsThe cursor for your next call.
next_after_keyNon-null when the page was cut short at limit. Pass it back together with next_since_ts so records sharing the last timestamp are not skipped.
kind"listen" or "broadcast", echoing which door you called.
completeSee below. It is a real signal — do not ignore it.

What complete means

complete:true means the server is certifying that the page contains everything on the route past your cursor. Both values reproduce, and the conditions are distinguishable:

So: if complete is false and next_after_key is set, page again. If complete is false and next_after_key is null, you have what the server can see but it will not promise that is all of it — poll again shortly rather than concluding the route is exhausted.

Paging

Take next_since_ts and next_after_key from one page and pass them into the next. On a route holding k1 and k2:

curl -s -H "X-Ocean-Key: $OCEAN_KEY" \
  "$API/v2/listen/_docs_probe/tiny1787592542?limit=1"
{"ok":true,"route":"_docs_probe/tiny1787592542","kind":"listen","mode":"poll",
 "events":[{"key":"k1","ts":1787592547650,"value":{"n":1}}],
 "returned":1,"next_since_ts":1787592547650,"complete":false,"next_after_key":"k1"}
curl -s -H "X-Ocean-Key: $OCEAN_KEY" \
  "$API/v2/listen/_docs_probe/tiny1787592542?limit=1&since_ts=1787592547650&after_key=k1"
{"ok":true,"route":"_docs_probe/tiny1787592542","kind":"listen","mode":"poll",
 "events":[{"key":"k2","ts":1787592547929,"value":{"n":2}}],
 "returned":1,"next_since_ts":1787592547929,"complete":true,"next_after_key":null}

last=N on an exact route is just limit=N — it returns the OLDEST N

On a wildcard route last=N means "the newest N". On an exact route it does not: it is an alias for limit, and since events come back oldest-first you get the oldest N from your cursor. Measured on a route holding evt-1 (ts 1787591990858), evt-2 (1787592042810), evt-3 (1787592054490), evt-4 and evt-5:

curl -s -H "X-Ocean-Key: $OCEAN_KEY" \
  "$API/v2/listen/_docs_probe/realtime?last=1"
{"ok":true,"route":"_docs_probe/realtime","kind":"listen","mode":"poll",
 "events":[{"key":"evt-1","ts":1787591990858,"value":{"kind":"temperature","celsius":21.5}}],
 "returned":1,"next_since_ts":1787591990858,"complete":false,"next_after_key":"evt-1"}

?last=2 and ?limit=2 on that route returned byte-identical bodies — both evt-1 and evt-2, "complete":false, "next_after_key":"evt-2". If you want the most recent records on a single route, do not use last — use from=latest to park the cursor at now and read forward, or read the page and take the tail.

GET /v2/broadcast/{route}

Same handler, same parameters, same events, same errors. The only difference in the response is "kind":"broadcast":

curl -s -H "X-Ocean-Key: $OCEAN_KEY" \
  "$API/v2/broadcast/_docs_probe/realtime?since_ts=0&limit=10"
{"ok":true,"route":"_docs_probe/realtime","kind":"broadcast","mode":"poll",
 "events":[{"key":"evt-1","ts":1787591990858,"value":{"kind":"temperature","celsius":21.5}},
           {"key":"evt-2","ts":1787592042810,"value":{"kind":"temperature","celsius":22.75}},
           {"key":"evt-3","ts":1787592054490,"value":{"kind":"temperature","celsius":23}},
           {"key":"evt-4","ts":1787592395366,"value":{"kind":"temperature","celsius":24.5}},
           {"key":"evt-5","ts":1787592418518,"value":{"kind":"temperature","celsius":26}}],
 "returned":5,"next_since_ts":1787592418518,"complete":true,"next_after_key":null}

It accepts mode=sse too and returns the same frames as /v2/listen/, it rejects an unknown query parameter the same way, and a wildcard without last=N gives the same 400. There is no publish side:

curl -s -X POST "$API/v2/broadcast/_docs_probe/realtime" \
  -H "X-Ocean-Key: $OCEAN_KEY" -H "Content-Type: application/json" -d '{"hello":"world"}'
{"ok":false,"error":"no /v2 endpoint matches this method and path","code":"NOT_FOUND"}

Server-Sent Events

Step 1 — mark the route

SSE is refused on a route that has not been marked as streaming. Marking is a one-time call per route and requires write access to that exact route.

curl -s -X POST "$API/v2/stream" \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"route":"_docs_probe/rtv1787593453","on":true}'
{"ok":true,"route":"_docs_probe/rtv1787593453","streaming":true,"affinity":true,
 "home_index":2,"home_here":false}

home_index and home_here are informational — which node in the fleet is this route's home, and whether the node answering you is it. Neither affects how you call the door.

Read the mark back with GET /v2/stream?route=..., and remove it with {"on":false}, which returns "streaming":false,"affinity":false and makes the next mode=sse call fail again. GET /v2/stream without a route parameter returns 400 MISSING_FIELD; so does POST /v2/stream without a route field. Marking a route that has never been written returns 403.

Without the mark:

curl -s -H "X-Ocean-Key: $OCEAN_KEY" \
  "$API/v2/listen/_docs_probe/hooks?mode=sse"
{"ok":false,
 "error":"SSE requires route marked streaming (POST /v2/stream {\"route\",\"on\":true}); use poll without mark",
 "code":"SSE_REQUIRES_MARK"}

Step 2 — open the stream

Ask for SSE either with Accept: text/event-stream or with ?mode=sse. Each works on its own — a request carrying only the Accept header and no mode parameter returned a stream. The response is 200 with content-type: text/event-stream, cache-control: no-cache and access-control-allow-origin: *.

curl -sN --max-time 60 \
  -H "X-Ocean-Key: $OCEAN_KEY" -H "Accept: text/event-stream" \
  "$API/v2/listen/_docs_probe/rtv1787593453?mode=sse"

The first frame is a catch-up batch of what is already on the route, then live frames as writes land, then : ping comments during idle. This is a real transcript — the connection was opened, then evt-2 and evt-3 were written from a second shell while it was held:

event: message
data: {"events":[{"key":"evt-1","ts":1787593457966,"value":{"kind":"temperature","celsius":21.5}}]}

event: message
data: {"events":[{"key":"evt-2","ts":1787593552743,"value":{"kind":"temperature","celsius":22.75}}]}

: ping

event: message
data: {"events":[{"key":"evt-3","ts":1787593574485,"value":{"kind":"temperature","celsius":23}}]}

Every event frame is event: message with a data: line holding {"events":[…]} — the same event objects the poll door returns. A frame may carry more than one event; a later run against the same route seeded four events in a single opening frame.

Suppressing the catch-up batch

Pass since_ts when you open the stream and you get only what arrives after that point.

curl -sN --max-time 40 \
  -H "X-Ocean-Key: $OCEAN_KEY" -H "Accept: text/event-stream" \
  "$API/v2/listen/_docs_probe/rtv1787593453?mode=sse&since_ts=1787593574485"

No seed frame; the first thing that arrived was the next write. limit is accepted by the validator and ignored on the SSE arm — opening a stream with mode=sse&limit=1 against a four-record route still seeded all four events in the first frame.

Latency, measured

One timed sample on an exact route, clock on the client, comparing when POST /v2/drop was issued against when the frame was read: POST issued 1787593627273 → the drop's own ts 1787593628888 → frame read 1787593631267. That is 3.99 s end to end, 2.38 s after the drop's own timestamp.

One sample is not a service level. Treat single-digit seconds as the working assumption and do not build anything that needs sub-second delivery.

Heartbeats and connection lifetime

An idle stream emits : ping — an SSE comment your client should ignore — roughly every 15 s (measured 15.24 s between two consecutive pings). The server closes the connection after about five minutes. A stream held with curl --max-time 400 against an idle route was closed by the server at 301.0 s after 19 heartbeats. Browsers' EventSource reconnects on its own; anything else you write must reconnect, and should resume from the ts of the last event it saw via since_ts.

Wildcard listens

Append /* to listen across every route under a prefix. A wildcard listen must name a bound — last=N is required, and neither since_ts nor limit substitutes for it:

curl -s -H "X-Ocean-Key: $OCEAN_KEY" "$API/v2/listen/docsprobe/rt/*"
{"ok":false,"error":"a wildcard listen must ask for a bounded window: add last=N (the newest N across descendants, served from the prefix index). since_ts across every descendant cannot be answered in bounded time, so it is no longer offered — page a single route with /v2/{route}?cursor= or listen to it with after_key=","code":"WILDCARD_NEEDS_LAST"}

With the bound, you get the newest N across all descendants, returned oldest-first. On a prefix holding five records, last=2 returns the two newest, not the two oldest:

curl -s -H "X-Ocean-Key: $OCEAN_KEY" "$API/v2/listen/docsprobe/rt/*?last=2"
{"ok":true,"route":"docsprobe/rt/*","kind":"listen","mode":"poll",
 "events":[{"key":"o2","ts":1787592243834,"value":{"amount":750}},
           {"key":"o3","ts":1787592281930,"value":{"amount":111}}],
 "returned":2,"next_since_ts":1787592281930,"complete":true,"next_after_key":null}

Two things to know before you build on this:

Wildcard over SSE

Mark the parent route, not the children. The parent must itself be a route you have written to: on a prefix whose parent route did not exist, POST /v2/stream returned 403 and the wildcard SSE call also returned 403 — while the wildcard poll call on the same prefix returned 200 with the child's records. Writing one record to the parent route, then marking it, made the SSE call work.

curl -sN --max-time 70 \
  -H "X-Ocean-Key: $OCEAN_KEY" -H "Accept: text/event-stream" \
  "$API/v2/listen/docsprobe/rt/*?last=3"

Real transcript. The seed frame is newest-first, live frames follow, and every event carries a route:

event: message
data: {"events":[{"key":"o3","ts":1787592281930,"value":{"amount":111},"route":"docsprobe/rt/orders"},
                 {"key":"o2","ts":1787592243834,"value":{"amount":750},"route":"docsprobe/rt/orders"},
                 {"key":"s2","ts":1787592233458,"value":{"celsius":25.5},"route":"docsprobe/rt/sensors"}]}

Route names starting with an underscore are not in the wildcard index

Wildcard listens are served from a prefix index that skips routes whose first character is _. A wildcard over such a prefix returns 200 with zero events, not an error, even when the child routes plainly hold records:

curl -s -H "X-Ocean-Key: $OCEAN_KEY" "$API/v2/listen/_docs_probe/*?last=5"
{"ok":true,"route":"_docs_probe/*","kind":"listen","mode":"poll","events":[],
 "returned":0,"next_since_ts":0,"complete":false,"next_after_key":null}

The identical call against docsprobe/rt/* returned its records. If you intend to use wildcard listens, do not start your route names with an underscore.

Webhooks

POST /v2/hooks — register

Registers a URL that InfiniteOcean POSTs to whenever the route is written. You need write access to the route, which means the route must already exist — registering against a route that was never written returns 403.

FieldRequiredNotes
routeyesThe data route to watch. Missing → 400 body must include route.
urlyesMissing → 400 body must include url. Registration does not validate the value at all — ftp://example.com/x and the literal string not a url were both accepted with 200. Every check happens at delivery time.
secretnoStored write-only and never returned by the read door. It is not used to sign anything today — see below.
curl -s -X POST "$API/v2/hooks" \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"route":"_docs_probe/hv1787593801","url":"https://webhook.site/3c44b197-b0ad-4051-af34-11e9e05d3549","secret":"s3cr3t-probe"}'
{"ok":true,"route":"_docs_probe/hv1787593801",
 "url":"https://webhook.site/3c44b197-b0ad-4051-af34-11e9e05d3549"}

Registration is not instant across the fleet. A GET /v2/hooks/{route} issued immediately after this call returned "hooks":[]; the same call 20 s later showed the hook. Do not treat a 200 here as "the next write will definitely fire".

A route can carry several hooks, and every write fans out to all of them. Three different URLs registered on one route produced three delivery jobs from a single drop. Registering the same URL again does not add a duplicate — the hook list stayed at three across a repeat registration.

What your endpoint receives

Verified against a public capture endpoint registered as the hook URL. The delivery is a POST with content-type: application/json and this body:

{"route":"_docs_probe/hv1787593801","key":"invoice-9",
 "url":"https://webhook.site/3c44b197-b0ad-4051-af34-11e9e05d3549"}

Three things to plan around:

Registration is not retroactive. A hook registered on a route already holding three records received nothing for them; the capture endpoint stayed at zero requests until the next new write, which arrived within about a minute.

GET /v2/hooks/{route} — list hooks and delivery receipts

curl -s -H "X-Ocean-Key: $OCEAN_KEY" "$API/v2/hooks/_docs_probe/hv1787593801"
{"ok":true,"route":"_docs_probe/hv1787593801",
 "hooks":[{"url":"https://webhook.site/3c44b197-b0ad-4051-af34-11e9e05d3549"}],
 "deliveries":[
   {"url":"https://webhook.site/3c44b197-b0ad-4051-af34-11e9e05d3549",
    "job":"fa037d1422b7879a5f66dc2faf19475b","status":"delivered"}]}

hooks[] carries only the URL — the secret is never returned. Status values observed:

statusMeaning
deliveredYour endpoint answered 2xx.
retryingAn attempt failed and more are queued. reason carries the cause, e.g. "http 405". The third-party response body is never included. A job against a URL that answers 405 was still retrying when last checked 100 s later.
failedTerminal. Observed for every policy refusal, with a reason naming which check refused it — see below.

Delivery receipts land in your own route

This surprises people: each receipt is also written into the watched route as a record keyed hook-delivery-{job}. It shows up in GET /v2/listen/{route} alongside your own data:

curl -s -H "X-Ocean-Key: $OCEAN_KEY" "$API/v2/listen/_docs_probe/hv1787593801?limit=30"
{"ok":true,"route":"_docs_probe/hv1787593801","kind":"listen","mode":"poll","events":[
  {"key":"pre-1","ts":1787593805890,"value":{"pre":true}},
  {"key":"invoice-9","ts":1787593847398,"value":{"status":"paid","amount":1900}},
  {"key":"hook-delivery-fa037d1422b7879a5f66dc2faf19475b","ts":1787593875130,
   "value":{"url":"https://webhook.site/3c44b197-b0ad-4051-af34-11e9e05d3549",
            "job":"fa037d1422b7879a5f66dc2faf19475b","status":"delivered"}},
  {"key":"job-a","ts":1787593895978,"value":{"n":"job-a"}}],
 "returned":13,"next_since_ts":1787593927162,"complete":false,"next_after_key":null}

If you both listen to a route and put a webhook on it, filter key values beginning hook-delivery- in your listener. Writing a receipt does not itself re-fire the hook — five data writes produced five jobs and five receipts, not ten jobs.

URLs that are refused

Registration always succeeds; the refusal shows up as a failed delivery with a reason naming the check, and no request is made. All three reasons below were produced from real registrations:

{"url":"http://127.0.0.1:6379/hook","job":"4d5ab1453665ce9864ca23dde1296f6a","status":"failed",
 "reason":"endpoint resolves to a blocked address (private/loopback/link-local/reserved)"}

{"url":"ftp://example.com/x","job":"b3633ceae1b1df2b2a4920316e0fc35c","status":"failed",
 "reason":"scheme not allowed — http/https only"}

{"url":"not a url","job":"d17c40e899cc9e8718086e50dfee6576","status":"failed",
 "reason":"unparseable url"}

Your endpoint must be reachable on the public internet over http or https.

Removing a webhook

There is no delete door. DELETE /v2/hooks/{route} returns 404, and so does GET /v2/hooks without a route. Point the URL somewhere inert if you need to stop deliveries.

Errors

StatuscodeWhen
400UNKNOWN_PARAMA query parameter outside since_ts, limit, after_key, timeout_ms, mode, last, from. ?foo=1 → unknown query parameter "foo" — nothing was applied.
400INVALID_INPUTsince_ts=abc, timeout_ms=abc, limit=0, last=0, an unparseable from, an after_key over 512 characters.
400WILDCARD_NEEDS_LASTA wildcard listen without last=N, including one that passes since_ts or limit instead.
400SSE_REQUIRES_MARKmode=sse on a route that has not been marked with POST /v2/stream.
400MISSING_FIELDGET /v2/stream with no route; POST /v2/stream with no route; POST /v2/hooks with no route or no url.
400INVALID_JSONA POST /v2/hooks or POST /v2/stream body that is not a JSON object.
401KEY_REQUIREDNo X-Ocean-Key header.
401UNKNOWN_KEYThe key is not recognised.
403FORBIDDENNo grant on the route — including a route that has never been written to, registering a hook on one, marking one for streaming, or opening a wildcard SSE stream whose prefix route does not exist.
404NOT_FOUNDMethod/path combination that does not exist: POST /v2/listen/…, POST /v2/broadcast/…, DELETE /v2/hooks/…, GET /v2/hooks with no route, GET /v2/listen/ with an empty route.

Limits and things this does not do

Base URL https://api2.infiniteocean.io · authenticate with the X-Ocean-Key header.