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.
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:
/v2/broadcast/ is not an ephemeral publish channel. It is a second name for the read door. POST /v2/broadcast/{route} returns 404 — there is no way to send a message that is not stored. Everything a listener receives is a drop that was written with POST /v2/drop.evt-1, evt-2, evt-3, evt-4: rewriting evt-1 left the route with four events, with evt-1 now last and holding the new value. If you want every occurrence, write a unique key per occurrence.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.
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.
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.
| Parameter | Meaning |
|---|---|
since_ts | Epoch milliseconds. Returns records with ts strictly greater. Default 0 (everything). A non-integer returns 400 INVALID_INPUT. |
limit | Maximum 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_key | Paging companion to since_ts — see below. A 513-character value returns 400 after_key too long. |
timeout_ms | Long-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. |
mode | poll (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. |
from | 0, 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. |
last | A bounded window of N records. Required on wildcard routes; on an exact route it is an alias for limit — read the note below. |
| Field | Meaning |
|---|---|
events[] | Ascending by (ts, key). Each event is {"key","ts","value"}. value is the drop payload verbatim. |
returned | Number of events in this page. |
next_since_ts | The cursor for your next call. |
next_after_key | Non-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. |
complete | See below. It is a real signal — do not ignore it. |
complete meanscomplete: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:
limit. A route holding two records, asked with limit=1, returned "complete":false with "next_after_key":"k1". The same route with limit=10 returned both records and "complete":true."complete":false and "next_after_key":null, and stayed that way across three polls 4 s apart. Minutes later the identical call on the same route returned "complete":true. The server will not certify completeness until its durable index for the route has landed.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.
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}
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.
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"}
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"}
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.
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.
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.
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.
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:
/* covers descendants, not the prefix route itself. A prefix route holding its own record root, with one child route holding x1, returned only x1 from the wildcard.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"}]}
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.
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.
| Field | Required | Notes |
|---|---|---|
route | yes | The data route to watch. Missing → 400 body must include route. |
url | yes | Missing → 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. |
secret | no | Stored 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.
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:
GET /v2/entity/{route}/{key} if you need its contents.accept, content-length, content-type, host, user-agent — with a secret registered on that hook. Authenticate the callback some other way: a secret path segment or a query string on the URL you register.(route, key). In this run five jobs produced exactly five POSTs, one each — but they came from three distinct source addresses, and nothing in the response contract promises exactly-once.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.
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:
| status | Meaning |
|---|---|
delivered | Your endpoint answered 2xx. |
retrying | An 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. |
failed | Terminal. Observed for every policy refusal, with a reason naming which check refused it — see below. |
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.
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.
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.
| Status | code | When |
|---|---|---|
| 400 | UNKNOWN_PARAM | A query parameter outside since_ts, limit, after_key, timeout_ms, mode, last, from. ?foo=1 → unknown query parameter "foo" — nothing was applied. |
| 400 | INVALID_INPUT | since_ts=abc, timeout_ms=abc, limit=0, last=0, an unparseable from, an after_key over 512 characters. |
| 400 | WILDCARD_NEEDS_LAST | A wildcard listen without last=N, including one that passes since_ts or limit instead. |
| 400 | SSE_REQUIRES_MARK | mode=sse on a route that has not been marked with POST /v2/stream. |
| 400 | MISSING_FIELD | GET /v2/stream with no route; POST /v2/stream with no route; POST /v2/hooks with no route or no url. |
| 400 | INVALID_JSON | A POST /v2/hooks or POST /v2/stream body that is not a JSON object. |
| 401 | KEY_REQUIRED | No X-Ocean-Key header. |
| 401 | UNKNOWN_KEY | The key is not recognised. |
| 403 | FORBIDDEN | No 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. |
| 404 | NOT_FOUND | Method/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. |
limit defaults to 50. A value of 1000 returns 200 rather than an error.timeout_ms is capped far below what you can ask for — asks from 1 s to 60 s all returned in 2.7–4.0 s on an idle route.since_ts.mode value silently falls back to polling instead of erroring.last=N, skip routes whose names begin with _, exclude the prefix route's own records, and omit the source route in the poll response (SSE includes it).{route, key, url} only and are unsigned. Treat them as at-least-once.https://api2.infiniteocean.io · authenticate with the X-Ocean-Key header.