InfiniteOceanv2

Quickstart

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.

What this is

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.

Base URL and a keyless health check

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.

Getting an account and a key

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.

POST /v2/auth/request

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.

GET /v2/auth/redeem?token=…

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:

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.

POST /v2/auth/verify

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:

export OCEAN_KEY='ok_<64 hex characters>'

Every example below assumes $OCEAN_KEY is set.

Authenticating requests

Send the key in the X-Ocean-Key header. That is the only form these endpoints accept.

GET /v2/whoami

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

A v1 key does not work on v2

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.

End to end: write a drop, read it back

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.

POST /v2/drop

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

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.

Importing with your own timestamps

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

GET /v2/entity/{route}/{key}

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.

Updating: write the same key again

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.

Listing a route

GET /v2/list/{route}

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

Paging, and the trap in next_cursor

Page 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.

What complete and index mean

These 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.

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.

Do not use field and value yet

They 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

Writing several drops in one call

POST /v2/drops

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

Durability: the receipt

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.

GET /v2/receipt/{seq}

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.

Errors you will actually hit

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.

Authentication

# 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

Access control

# 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

Malformed requests

# 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.

Methods that are not routed at all

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.

Sign-in

# 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.

Server-side

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.

Cheat sheet

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.

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