Get a key by magic link, confirm it with GET /v2/whoami, then write a drop and read it back — every request and response on this page was re-executed against the live api2.infiniteocean.io by a second pass and pasted verbatim.
InfiniteOcean is a hosted store for JSON documents called drops: you write a drop with a
route (a namespace you own) and a key (an id inside it), and you read it back by the same pair.
Writing the same route and key again replaces the value, so a route behaves like a keyed table that you address over plain HTTP.
Everything on this page was executed against the live API and the responses are pasted verbatim.
The v2 API lives at https://api2.infiniteocean.io and every path starts with /v2/.
GET /v2/health takes no key. Use it to confirm you can reach the service before you worry about credentials.
curl -s -w '\n%{http_code}\n' https://api2.infiniteocean.io/v2/health
It returns 200. The body is a large operations object; the two fields you care about are the first two, and
the rest is internal telemetry with no stable contract — do not build against it. Truncated here:
{"ok":true,"node":"hil","attribution":true,"compaction_lease":true,"uncompacted":196, … }
200
node names which of the five nodes answered you, and it is truthful: addressing each node directly returned
its own name (de, fi, ash, hil, sin). Which one you reach
through the load balancer is not something you choose — but do not assume it rotates either. Ten consecutive calls from one
client all landed on hil. Either way it changes nothing about what you can read or write.
Sign-in is by email: you ask for a sign-in mail, and it carries a six-digit code and a link that do the same thing. Type the code into your app, or click the link — either one returns a key, and using one ends the other. There is no password, and no key-issuing endpoint other than the two below.
Asks for a sign-in mail — a six-digit code and a link, either of which signs you in. No key required — this is how you get one.
Required body field: email (string). No other field is accepted.
curl -s -w '\n%{http_code}\n' -X POST https://api2.infiniteocean.io/v2/auth/request \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com"}'
{"ok":true,"message":"If that address can receive mail, a link is on its way"}
202
The 202 tells you nothing about the address. The probe that produced this exact response used
docs-probe-verify@example.invalid, which cannot receive mail. The endpoint answers identically for a deliverable
address and an undeliverable one — deliberately, so it cannot be used to test whether an address has an account. If no mail
arrives, the reason is in your inbox or your address, not in this response.
Exchanges the token from the link for an account key. The key is shown in this response body; treat it as shown once and store it before you close the terminal.
Required query parameter: token. It is the only parameter this endpoint accepts — anything
else is a 400 UNKNOWN_PARAM, verified with ?token=x&foo=1.
Two things about this call will surprise you if nobody says them:
Accept header decides the behaviour. If the request asks for text/html
— which is what a browser does — the server answers 302 to the dashboard and hands the token on in the
redirect URL. A token that does not exist at all still gets that redirect, so the token is plainly not examined on this
path. Only a non-HTML request reaches the code that judges the token.token parameter produce the identical 401 below,
so a failed sign-in never tells an attacker which case it was — and never tells you either. If a link stops working,
request a new one.The redirect branch, verified with a token that does not exist:
curl -s -D - -o /dev/null -H 'Accept: text/html' \
"https://api2.infiniteocean.io/v2/auth/redeem?token=bogus-does-not-exist"
HTTP/2 302
location: /dash/index.html?token=bogus-does-not-exist
The redeeming branch, same non-existent token:
curl -s -w '\n%{http_code}\n' -H 'Accept: application/json' \
"https://api2.infiniteocean.io/v2/auth/redeem?token=bogus-does-not-exist"
{"ok":false,"error":"invalid or expired token","code":"INVALID_TOKEN"}
401
With a real token from your email, the same call returns 200 and a body containing your key. A v2 account
key is the literal prefix ok_ followed by 64 lowercase hex characters — 67 characters in total; an existing v2
key was checked against exactly that pattern. Check the shape before you store it.
The same sign-in, typed instead of clicked. Every sign-in mail carries a six-digit code above the link; send it here together with the address it was mailed to. No key required — this, like the redeem above, is how you get one.
Required body fields: email (string) and code (string). Any other field is
400 UNKNOWN_FIELD. Send the code as a string: as a JSON number, 004821 would arrive as
4821 and could never match, so a number is refused with a message that says why. Spaces and dashes inside the
code are ignored, so 482 193 works.
curl -s -w '\n%{http_code}\n' -X POST https://api2.infiniteocean.io/v2/auth/verify -H "Content-Type: application/json" -d '{"email":"docs-probe-verify@example.invalid","code":"000000"}'
{"ok":false,"error":"invalid or expired code","code":"INVALID_CODE"}
401
A wrong code, an expired one, one already used, and an address with no code outstanding all get that identical
401 — deliberately, so this door cannot be used to test whether an address has an account. With the right code
the response is 200 and the same {"key":"ok_…"} body the redeem above returns.
curl -s -w '\n%{http_code}\n' -X POST https://api2.infiniteocean.io/v2/auth/verify -H "Content-Type: application/json" -d '{"email":"docs-probe-verify@example.invalid","code":482193}'
{"ok":false,"error":"body must include email and code, both as strings (a code sent as a number loses its leading zeros); nothing was applied","code":"MISSING_FIELD"}
400
Four things to know before you build a sign-in screen on it:
429 RATE_LIMITED — even for the right code.
Asking for a new code does not reset it: if it did, every request would buy a guesser five more tries. The link in the
same mail is not affected, so someone locked out of typing can still click.503 BUSY means too many code checks were in flight on that server at once. That attempt
was not counted and the code still works — retry in a few seconds.export OCEAN_KEY='ok_<64 hex characters>'
Every example below assumes $OCEAN_KEY is set.
Send the key in the X-Ocean-Key header. That is the only form these endpoints accept.
Authorization: Bearer <key> is not accepted — an account key sent that way is
ignored and the request fails with 401 KEY_REQUIRED.?key= query parameter. Sending one fails with 400 UNKNOWN_PARAM before
anything is applied.Confirms the key works and tells you who the server thinks you are. Run this first after redeeming; if it does not
return 200, nothing else will work either.
curl -s -w '\n%{http_code}\n' https://api2.infiniteocean.io/v2/whoami \
-H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"pid":"9a92e0aed98f","handle":"ftft1787595422h","key_kind":"account"}
200
pid — your public id. This is the principal that owns routes; it is what appears in access-control rules.handle — a human-readable name for the same principal.key_kind — account for a key redeemed from a magic link. This is the full-authority key
for the account.v2 has its own identity store. Keys issued by v1 at api.infiniteocean.io are not known to
api2.infiniteocean.io, and there is no import — you redeem a fresh magic link to get a v2 key. This was
checked with a key that works on v1 in the same minute it was rejected by v2:
# same key, both calls
curl -s -o /dev/null -w '%{http_code}\n' https://api.infiniteocean.io/context -H "X-Ocean-Key: $V1_KEY"
curl -s -w '\n%{http_code}\n' https://api2.infiniteocean.io/v2/whoami -H "X-Ocean-Key: $V1_KEY"
200
{"ok":false,"error":"unknown key — not authenticated","code":"UNKNOWN_KEY"}
401
The shapes differ too: the v1 key is 43 characters with no prefix, a v2 key is 67 characters starting ok_.
If you are seeing UNKNOWN_KEY on every v2 call, this is almost always why. Note the two failures are distinct
and mean different things: KEY_REQUIRED means no X-Ocean-Key header arrived,
UNKNOWN_KEY means one arrived and is not a v2 key.
Two calls, and you have stored and retrieved data. Pick any route name you like —
_docs_probe/verify-0824 is the one used to produce the output below. The first write to a route that nobody
owns claims it for you; from then on it is yours. There is no separate "create a route" step.
Writes one drop.
Required body fields: route (string), key (string), payload
(any JSON value — object, array, string, number, boolean and null were all accepted).
Optional: ts (integer, epoch milliseconds) to set the drop's timestamp yourself, for
importing existing data.
Any other top-level field is rejected with 400 UNKNOWN_FIELD and nothing is written.
curl -s -w '\n%{http_code}\n' -X POST https://api2.infiniteocean.io/v2/drop \
-H "Content-Type: application/json" \
-H "X-Ocean-Key: $OCEAN_KEY" \
-d '{"route":"_docs_probe/verify-0824","key":"note-1","payload":{"title":"Buy milk","done":false}}'
{"ok":true,"seq":1787577342970782,"offset":0,"len":108,"ts":1787598299454,"state":"accepted","rule_created":true,"drop_id":"1787577342970782","route":"_docs_probe/verify-0824","key":"note-1"}
200
state:"accepted" — the write has been taken and is readable immediately, and is on its way to the object
stores. See Durability below for how to confirm it got there.rule_created:true — this write claimed the route. You will see it exactly once per route; every later
write to the same route reports false.seq / drop_id — the write's sequence number, the same value in two fields. Keep it if you
want to check durability later.ts — the drop's timestamp in epoch milliseconds, echoed back.Expect the route-claiming write to be much slower than the ones after it. In a back-to-back pair on a
brand-new route, the claiming write took 4.92s and the next took 0.32s. Those are two samples, not
a published latency figure — the point is only that your client timeout must survive the first write.
Pass ts as epoch milliseconds and the drop carries that timestamp instead of the arrival time — which is
what you want when moving existing records in and preserving their history.
curl -s -w '\n%{http_code}\n' -X POST https://api2.infiniteocean.io/v2/drop \
-H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
-d '{"route":"_docs_probe/verify-0824","key":"backdated","payload":{"title":"Imported"},"ts":1750000000000}'
{"ok":true,"seq":1787577342970787,"offset":0,"len":98,"ts":1750000000000,"state":"accepted","rule_created":false,"drop_id":"1787577342970787","route":"_docs_probe/verify-0824","key":"backdated"}
200
The value is echoed in the receipt and is the ts the drop then reports in a listing. A non-integer
ts is rejected outright:
{"ok":false,"error":"ts must be an integer epoch ms — nothing was applied","code":"BAD_TS"}
400
Reads the current value of one drop. The route and key are the URL path — a route containing slashes just becomes more path segments, and the last segment is the key.
curl -s -w '\n%{http_code}\n' \
https://api2.infiniteocean.io/v2/entity/_docs_probe/verify-0824/note-1 \
-H "X-Ocean-Key: $OCEAN_KEY"
{"title":"Buy milk","done":false}
200
The response body is your payload, with no envelope around it — not {"ok":true,...}. If
you stored a string you get a JSON string back, if you stored an array you get an array:
curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/verify-0824/plain -H "X-Ocean-Key: $OCEAN_KEY"
curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/verify-0824/arr -H "X-Ocean-Key: $OCEAN_KEY"
"just text"
[1,2,3]
This endpoint takes no query parameters at all — ?pretty=1 is a 400 UNKNOWN_PARAM.
Reads are served by any node in the fleet. A settled drop was requested directly from all five nodes and every one returned the same value.
There is no PUT or PATCH. Writing an existing route + key supersedes the previous value; a
read afterwards returns the new one.
curl -s -X POST https://api2.infiniteocean.io/v2/drop \
-H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
-d '{"route":"_docs_probe/verify-0824","key":"note-1","payload":{"title":"Buy milk","done":true}}'
curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/verify-0824/note-1 \
-H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"seq":1787577342970786,"offset":0,"len":107,"ts":1787598317082,"state":"accepted","rule_created":false,"drop_id":"1787577342970786","route":"_docs_probe/verify-0824","key":"note-1"}
{"title":"Buy milk","done":true}
Because the update is keyed, retrying a write that may or may not have landed is safe: the same key with the same payload leaves the same value.
Returns the current value of every key in a route, ordered by key ascending.
Optional query parameters: limit, cursor, field,
value. Anything else is a 400 — see Errors.
curl -s -w '\n%{http_code}\n' \
https://api2.infiniteocean.io/v2/list/_docs_probe/verify-0824 \
-H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"items":[{"key":"note-1","ts":1787598299454,"value":{"title":"Buy milk","done":false}},{"key":"note-2","ts":1787598299785,"value":{"title":"Ship docs","done":false}}],"returned":2,"complete":false,"index":false,"next_cursor":null}
200
next_cursorPage with limit, then feed next_cursor back as cursor. The next page starts after
that key.
curl -s "https://api2.infiniteocean.io/v2/list/_docs_probe/qs?limit=2" -H "X-Ocean-Key: $OCEAN_KEY"
curl -s "https://api2.infiniteocean.io/v2/list/_docs_probe/qs?limit=2&cursor=note-2" -H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"items":[{"key":"note-1","ts":1787597287602,"value":{"title":"First drop","done":true}},{"key":"note-2","ts":1787597360706,"value":{"title":"Second"}}],"returned":2,"complete":false,"index":true,"next_cursor":"note-2"}
{"ok":true,"items":[{"key":"note-3","ts":1787597360706,"value":{"title":"Third"}},{"key":"note-4","ts":1787597380686,"value":{"title":"Receipt demo"}}],"returned":2,"complete":false,"index":true,"next_cursor":"note-4"}
A non-null next_cursor does not mean there is more data. Whenever a page fills to
limit, a cursor comes back — even if that was the last item in the route. On a route holding exactly six keys,
?limit=6 returned all six and a cursor, and following it returned an empty page:
curl -s "https://api2.infiniteocean.io/v2/list/_docs_probe/qs?limit=6" -H "X-Ocean-Key: $OCEAN_KEY"
curl -s "https://api2.infiniteocean.io/v2/list/_docs_probe/qs?limit=6&cursor=plain" -H "X-Ocean-Key: $OCEAN_KEY"
# first call — all six keys came back, and so did a cursor
"returned":6,"complete":false,"index":true,"next_cursor":"plain"
# following that cursor
{"ok":true,"items":[],"returned":0,"complete":true,"index":true,"next_cursor":null}
So the loop terminates on next_cursor == null, and a correct client must be willing to make one final
request that comes back empty. Do not stop early on the assumption that a short page is the last one, and do not treat a
cursor as proof that more rows exist.
complete and index meanThese two flags are the honest answer to "is this everything?", and you will see complete:false on a
route you just created. That does not mean data is missing.
index:true — the page was served from the route's durable index.complete:true — this page is everything that remains in the route. It requires a durable index, no
further page, and a clean read.complete:false — the server will not assert the page is exhaustive. That happens for two different
reasons: the page was cut short by limit (seen above, with index:true), or the route is too
freshly written for compaction to have folded its durable index yet (index:false). In both cases the items
returned are current and correct.Both states, from the same account minutes apart — a route written seconds earlier, and a route that had settled:
# route written seconds ago
"returned":2 "complete":false "index":false "next_cursor":null
# route that has been folded
"returned":17 "complete":true "index":true "next_cursor":null
Treat complete:false as "possibly more", not as an error. The lag is temporary: a route that answered
complete:false, index:false when freshly written answered complete:true, index:true when the same
listing was repeated later.
field and value yetThey are the indexed-lookup form of this endpoint and need a secondary index declared for that field first. On a route with no such index they do not error — they return an empty page that claims to be complete, which is indistinguishable from "nothing matched":
curl -s -w '\n%{http_code}\n' \
"https://api2.infiniteocean.io/v2/list/_docs_probe/verify-0824?field=title&value=Imported" \
-H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"items":[],"returned":0,"complete":true,"index":true,"next_cursor":null}
200
That route does contain a drop whose title is Imported, and the same empty answer came
back on a freshly written route and on a fully folded one — so this is not compaction lag. For ad-hoc filtering use
POST /v2/query, which scans the route and needs no index:
curl -s -w '\n%{http_code}\n' -X POST https://api2.infiniteocean.io/v2/query \
-H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
-d '{"prefix":"_docs_probe/quickstart-demo","filter":{"title":"Imported"},"limit":10}'
{"ok":true,"scope":"squery.join_s1","results":[{"key":"backdated","ts":1750000000000,"payload":{"title":"Imported"}}],"drops":[{"key":"backdated","ts":1750000000000,"payload":{"title":"Imported"}}],"scanned":8,"matched":1,"returned":1,"truncated":false,"complete":true,"catalog_complete":true,"time_ms":998}
200
Queries have their own page; the example is here only so a silent empty result does not stop you. Supplying one half of
the field/value pair is at least loud about it — either half alone gives:
{"ok":false,"error":"field and value must both be set for a filtered list — nothing was applied","code":"PARTIAL_FILTER"}
400
Same semantics as /v2/drop, one route, many keys, one round trip. Note the plural path.
Required body fields: route (string) and records (array), each record with
key and payload.
curl -s -w '\n%{http_code}\n' -X POST https://api2.infiniteocean.io/v2/drops \
-H "Content-Type: application/json" \
-H "X-Ocean-Key: $OCEAN_KEY" \
-d '{"route":"_docs_probe/verify-0824","records":[{"key":"b-1","payload":{"title":"Second"}},{"key":"b-2","payload":{"title":"Third"}}]}'
{"ok":true,"seq":1787577342970806,"seq_last":1787577342970806,"count":2,"state":"accepted","rule_created":false,"multi_batch":false,"route":"_docs_probe/verify-0824"}
200
count is how many records were written; seq and seq_last bound the sequence
range. Note that for a batch written in one go these two are the same value — the batch has one sequence number, not one
per record.
Use this instead of a loop. Measured back to back from the same client: ten separate
POST /v2/drop calls took 8.58s, and one POST /v2/drops carrying the same ten records
took 0.31s. A sequential loop pays the full round trip per drop.
The two ways to get the body wrong have their own messages:
# route or records missing
{"ok":false,"error":"body must include route and records — nothing was applied","code":"MISSING_FIELD"} 400
# a record that is not an object with both key and payload
{"ok":false,"error":"record 0 must be an object with key and payload — nothing was applied","code":"BAD_RECORD"} 400
A 200 from /v2/drop means state:"accepted" — the accepting node has taken the
record and will serve it immediately, but the object stores have not confirmed it yet. If you need to know it reached
durable cloud storage, poll the receipt with the seq from the write.
curl -s -w '\n%{http_code}\n' \
https://api2.infiniteocean.io/v2/receipt/1787577342970807 \
-H "X-Ocean-Key: $OCEAN_KEY"
Immediately after the write:
{"ok":true,"seq":1787577342970807,"state":"accepted","node":"hil"}
200
The same sequence twenty seconds later:
{"ok":true,"seq":1787577342970807,"state":"durable","node":"hil"}
200
durable means the object store acknowledged the write. Treat any state string you do not recognise as
non-terminal and keep polling. A sequence number that does not exist returns 404:
{"ok":false,"error":"not found","code":"NOT_FOUND"}
404
You do not need to poll receipts in normal use. Reach for them when you are importing data and want positive
confirmation, or when a client crashed mid-write and you want to know whether a specific seq survived.
Almost every error response has the same shape: {"ok":false,"error":"…","code":"…"}. Branch on
code, never on the prose. Errors whose message ends "nothing was applied" are exactly that — the request was
rejected before any change was made, so retrying after a fix is safe. The one exception is the bodiless
405 described at the end of this section, which gives you nothing to branch on but the status.
# no X-Ocean-Key header at all
{"ok":false,"error":"X-Ocean-Key required","code":"KEY_REQUIRED"} 401
# a header arrived, but the key is not a v2 key (a v1 key lands here)
{"ok":false,"error":"unknown key — not authenticated","code":"UNKNOWN_KEY"} 401
# reading a route owned by someone else
{"ok":false,"error":"authenticated key is not a principal on this route","code":"NOT_PRINCIPAL"} 403
# reading a route that has no owner yet — nobody has ever written to it
{"ok":false,"error":"forbidden","code":"FORBIDDEN"} 403
An unwritten route reads as 403, not 404. This holds for both
/v2/list/ and /v2/entity/. If a route you expect to exist returns FORBIDDEN, check
the spelling before you check your permissions — a typo in the route name is indistinguishable from a route you do not own.
A key that was never written to inside a route you do own returns 404:
{"ok":false,"error":"entity not found","code":"NOT_FOUND"} 404
# missing any of route / key / payload
{"ok":false,"error":"body must include route, key, and payload — nothing was applied","code":"MISSING_FIELD"} 400
# an extra top-level field, e.g. "tags"
{"ok":false,"error":"unknown body field — nothing was applied","code":"UNKNOWN_FIELD"} 400
# truncated or invalid JSON body
{"ok":false,"error":"body is not valid JSON — nothing was applied","code":"INVALID_JSON"} 400
# a query parameter the endpoint does not define, e.g. ?order=desc on /v2/list
{"ok":false,"error":"unknown query parameter \"order\" — nothing was applied","code":"UNKNOWN_PARAM"} 400
# a path that is not registered, or GET / PUT / DELETE on a path that does not take it
{"ok":false,"error":"no /v2 endpoint matches this method and path","code":"NOT_FOUND"} 404
Unknown query parameters are a hard 400, not a silent ignore. Each endpoint accepts a
fixed set and rejects everything else, so a parameter guessed from another API fails loudly instead of returning a page
that quietly ignored your filter. That is deliberate: you never get a result that looks filtered but is not.
The 404 NOT_FOUND above is what you get for GET, PUT and DELETE on a
path that does not accept them. PATCH and HEAD never reach that code — they are answered
405 with a zero-byte body, so there is no code field to read:
curl -s -o /dev/null -w '%{http_code}\n' -X PATCH https://api2.infiniteocean.io/v2/drop \
-H "X-Ocean-Key: $OCEAN_KEY" -d '{}'
405 # response body is 0 bytes
curl -s -o /dev/null -w '%{http_code}\n' -X PUT https://api2.infiniteocean.io/v2/drop \
-H "X-Ocean-Key: $OCEAN_KEY" -d '{}'
404 # {"ok":false,…,"code":"NOT_FOUND"}
OPTIONS is answered 200 with an empty body, which is what a browser's CORS preflight needs. If
your client parses every response as JSON, give it a path for an empty body or these three will crash it.
# body has no email
{"ok":false,"error":"body must include email — nothing was applied","code":"MISSING_FIELD"} 400
# email is not a valid address
{"ok":false,"error":"email is malformed — nothing was applied","code":"MALFORMED_EMAIL"} 400
# too many link requests for one address in quick succession
{"ok":false,"error":"too many link requests for this address — try later","code":"RATE_LIMITED"} 429
# redeem: token not accepted (an unknown token and a missing one both land here)
{"ok":false,"error":"invalid or expired token","code":"INVALID_TOKEN"} 401
The rate limit is per address. In a burst of eight requests for one fresh address, the first five returned
202 and the sixth, seventh and eighth returned 429; a different address in the same session was
still accepted straight afterwards. Do not retry a magic link request in a loop.
A 503 on a write — LOCAL_NOT_ACCEPTED when the accepting node could not seal the record, or
OVERLOADED on the sign-in path — means the operation did not happen and should be retried. Because writes
are keyed, retrying cannot produce a duplicate. Neither was reproducible against the live service, so no response body is
quoted here; branch on the status and the code.
GET /v2/health no key — is the service reachable
POST /v2/auth/request no key — {"email":"…"} → magic link
GET /v2/auth/redeem?token=… no key — token → your key
GET /v2/whoami who this key is
POST /v2/drop {"route","key","payload"[,"ts"]} → write one
POST /v2/drops {"route","records":[{"key","payload"}]} → write many
GET /v2/entity/{route}/{key} read one — body is the bare payload
GET /v2/list/{route} read all — ?limit= &cursor= &field= &value=
POST /v2/query {"prefix","filter","limit"} → scan and filter
GET /v2/receipt/{seq} accepted → durable
Keep your key out of shell history, source control and logs. It is bearer authority over every route you own.
https://api2.infiniteocean.io · authenticate with the X-Ocean-Key header.