Fetch one drop, page through a route, check a write's receipt, and list the routes your key can reach — including what complete:false actually means to a caller.
Four read endpoints. GET /v2/entity/… fetches one drop by key.
GET /v2/list/… walks a whole route, one page at a time.
GET /v2/receipt/… tells you how far a write you just made has got.
GET /v2/routes lists the routes your key can reach.
Every read needs your key in an X-Ocean-Key header. All examples below use
$OCEAN_KEY; substitute your own.
export OCEAN_KEY='<your-key>'
The examples on this page run against a route that was populated first, with seven drops
keyed city-001 … city-007:
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/eucities","key":"city-001",
"payload":{"name":"Lisbon","country":"PT","pop":544851}}'
Returns the current payload for one key. The response body is the payload JSON exactly as
you stored it — there is no envelope, no ok field, no metadata.
Content-Type is application/json.
curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/eucities/city-003 \
-H "X-Ocean-Key: $OCEAN_KEY"
{"name":"Oslo","country":"NO","pop":709037}
A payload does not have to be an object. Whatever JSON value you stored comes back as the
whole body, unwrapped. Three drops written as 42, "hello" and
[1,2,3] read back as:
curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/shapes/num -H "X-Ocean-Key: $OCEAN_KEY"
# 42
curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/shapes/str -H "X-Ocean-Key: $OCEAN_KEY"
# "hello"
curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/shapes/arr -H "X-Ocean-Key: $OCEAN_KEY"
# [1,2,3]
The same values appear inline under value in a list response.
Everything after /v2/entity/ is split at the last slash: what
comes before is the route, what comes after is the key. Routes may therefore contain slashes
freely — /v2/entity/_docs_probe/eucities/nested/mykey reads key
mykey from route _docs_probe/eucities/nested.
Both halves are percent-decoded after that split, so a key containing a slash or a
space works if you encode it. This request reads the key a b/c:
curl -s "https://api2.infiniteocean.io/v2/entity/_docs_probe/eucities/nested/a%20b%2Fc" \
-H "X-Ocean-Key: $OCEAN_KEY"
# {"note":"key with a space and a slash"}
Encoding the slashes in the route half works too — the split is done on the raw path, so
/v2/entity/_docs_probe%2Feucities/city-003 returns the same drop as the
unencoded form.
| Status | Body | When |
|---|---|---|
| 401 | {"ok":false,"error":"X-Ocean-Key required","code":"KEY_REQUIRED"} | Header missing or empty. |
| 401 | {"ok":false,"error":"unknown key — not authenticated","code":"UNKNOWN_KEY"} | Key is not a key this server knows. |
| 403 | {"ok":false,"error":"forbidden","code":"FORBIDDEN"} | The route has no access rule granting your key read. A route that has never been written to also lands here — you get 403, not 404. |
| 404 | {"ok":false,"error":"entity not found","code":"NOT_FOUND"} | The route is readable but that key has no drop. Also what a read on a different node returns for a short window after a write — see Cross-node reads. |
| 400 | {"ok":false,"error":"path does not name a valid resource","code":"BAD_PATH"} | No key segment, e.g. /v2/entity/_docs_probe, or an empty key segment, e.g. /v2/entity/_docs_probe/eucities/. |
| 404 | {"ok":false,"error":"no /v2 endpoint matches this method and path","code":"NOT_FOUND"} | Nothing after the prefix at all, e.g. /v2/entity/. |
| 400 | {"ok":false,"error":"unknown query parameter \"pretty\" — nothing was applied","code":"UNKNOWN_PARAM"} | This endpoint takes no query parameters. Any parameter at all is a hard 400. |
Returns one page of the latest value for every key in a route, sorted by key ascending. Always an envelope — never a bare array.
curl -s https://api2.infiniteocean.io/v2/list/_docs_probe/eucities \
-H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,
"items":[
{"key":"city-001","ts":1787593972810,"value":{"name":"Lisbon","country":"PT","pop":544851}},
{"key":"city-002","ts":1787593973092,"value":{"name":"Porto","country":"PT","pop":231962}},
{"key":"city-003","ts":1787593973393,"value":{"name":"Oslo","country":"NO","pop":709037}},
{"key":"city-004","ts":1787593973695,"value":{"name":"Bergen","country":"NO","pop":286930}},
{"key":"city-005","ts":1787593975110,"value":{"name":"Copenhagen","country":"DK","pop":644431}},
{"key":"city-006","ts":1787593975413,"value":{"name":"Aarhus","country":"DK","pop":285273}},
{"key":"city-007","ts":1787593975692,"value":{"name":"Helsinki","country":"FI","pop":658864}}],
"returned":7,"complete":true,"index":true,"next_cursor":null}
| Field | Type | Meaning |
|---|---|---|
items | array | One object per key: key (string), ts (write time, ms since epoch), value (your payload JSON, inline). |
returned | number | items.length. Never larger than limit. |
complete | bool | Whether the server can certify it showed you the whole route. Read the next section — this is the one field people get wrong. |
index | bool | Unfiltered lists only: whether a durable route index was found. false means the route is freshly written and the node is answering from its own recent-write state. On a filtered list (field+value) this field is always true and tells you nothing — see below. |
next_cursor | string or null | The last key on this page, to be passed back as cursor. null when the page was not filled. |
| Name | Default | Notes |
|---|---|---|
limit | 20 | 1–100. 0, 101, a negative number and a non-number are all 400 BAD_LIMIT, not a clamp. |
cursor | — | Exclusive: the page starts at the first key strictly greater than this string. |
field | — | Filtered list. Must be sent together with value. |
value | — | Filtered list. Must be sent together with field. An empty value= is 400 INVALID_INPUT. |
Anything else — offset, sort, page — is a hard 400
with UNKNOWN_PARAM. Nothing is silently ignored.
complete:false means, and how to pagecomplete:true is a strong claim: this route contains nothing you have not
now seen. complete:false means the node is not certifying
the answer. It does not mean the items you got are wrong; every item returned is
real. It means "there may be more that I cannot see from here". Three situations produce it,
and the envelope does not always separate them:
next_cursor is non-null. Keep paging.index:false. Measured on the
seven-city route seconds after it was written:
{"ok":true,"items":[…7 items…],"returned":7,
"complete":false,"index":false,"next_cursor":null}
Seven items, no cursor, and still complete:false. On that route the index
appeared about seven minutes after the last write, after which the identical request
returned complete:true,"index":true. Do not treat seven minutes as a number to
design against — it was measured once.index:true,
page not full, and still complete:false. The same route read from all five
nodes at one moment:
de -> returned 7 complete false index true
fi -> returned 7 complete false index true
ash -> returned 7 complete false index true
hil -> returned 7 complete true index true
sin -> returned 7 complete true index true
All five returned the same seven items; they disagreed only about whether they could
certify it. About four minutes later all five reported complete:true. Note
that the split is not "the node that took the writes versus the rest" — the writes went to
hil, and sin also said true.next_cursor:null does not mean you have everything. A page can
end with a null cursor and complete:false at the same time — that is the normal
shape for a recently written route. Stop paging on next_cursor:null; decide
whether to trust the result on complete.
#!/usr/bin/env bash
# Page a whole route. Stops on next_cursor:null; reports whether
# the last page certified completeness.
ROUTE=_docs_probe/eucities
CURSOR=""
COMPLETE=""
while :; do
if [ -z "$CURSOR" ]; then
PAGE=$(curl -s -G "https://api2.infiniteocean.io/v2/list/$ROUTE" \
--data "limit=100" -H "X-Ocean-Key: $OCEAN_KEY")
else
PAGE=$(curl -s -G "https://api2.infiniteocean.io/v2/list/$ROUTE" \
--data "limit=100" --data-urlencode "cursor=$CURSOR" \
-H "X-Ocean-Key: $OCEAN_KEY")
fi
echo "$PAGE" | jq -c '.items[]'
COMPLETE=$(echo "$PAGE" | jq -r '.complete')
CURSOR=$(echo "$PAGE" | jq -r '.next_cursor // empty')
[ -z "$CURSOR" ] && break
done
[ "$COMPLETE" = "true" ] || echo "WARNING: route not certified complete — retry later" >&2
If you need a certified-complete snapshot, re-run the walk until the final page reports
complete:true. On a quiet route that happens within minutes of the last write;
on a route being written continuously it may never happen, because a peer always has
something in flight.
curl -s "https://api2.infiniteocean.io/v2/list/_docs_probe/eucities?limit=3" \
-H "X-Ocean-Key: $OCEAN_KEY"
# {"ok":true,"items":[…city-001,city-002,city-003…],"returned":3,
# "complete":false,"index":true,"next_cursor":"city-003"}
curl -s "https://api2.infiniteocean.io/v2/list/_docs_probe/eucities?limit=3&cursor=city-003" \
-H "X-Ocean-Key: $OCEAN_KEY"
# {"ok":true,"items":[…city-004,city-005,city-006…],"returned":3,
# "complete":false,"index":true,"next_cursor":"city-006"}
curl -s "https://api2.infiniteocean.io/v2/list/_docs_probe/eucities?limit=3&cursor=city-006" \
-H "X-Ocean-Key: $OCEAN_KEY"
# {"ok":true,"items":[…city-007…],"returned":1,
# "complete":true,"index":true,"next_cursor":null}
The cursor does not have to be a key that exists — it is a plain string comparison, so
cursor=city-004a resumes at city-005. A cursor past the end of the
route returns an empty page:
curl -s "https://api2.infiniteocean.io/v2/list/_docs_probe/eucities?limit=3&cursor=zzzz" \
-H "X-Ocean-Key: $OCEAN_KEY"
# {"ok":true,"items":[],"returned":0,"complete":true,"index":true,"next_cursor":null}
The cursor value is compared byte-for-byte against your keys without being
percent-decoded first. If a key contains a character you must encode in a query string —
a space, &, %, #, + — the cursor you
send back does not match the key it came from, and rows are dropped with no error.
Measured. A route with exactly two keys, "s t" and "s!u":
curl -s "https://api2.infiniteocean.io/v2/list/_docs_probe/eucities/enc" \
-H "X-Ocean-Key: $OCEAN_KEY"
# {"ok":true,"items":[{"key":"s t",…},{"key":"s!u",…}],"returned":2,
# "complete":true,"index":true,"next_cursor":null}
curl -s "https://api2.infiniteocean.io/v2/list/_docs_probe/eucities/enc?limit=1" \
-H "X-Ocean-Key: $OCEAN_KEY"
# {"ok":true,"items":[{"key":"s t",…}],"returned":1,
# "complete":false,"index":true,"next_cursor":"s t"}
# Feed that cursor back, percent-encoded exactly as a query string requires:
curl -s "https://api2.infiniteocean.io/v2/list/_docs_probe/eucities/enc?limit=1&cursor=s%20t" \
-H "X-Ocean-Key: $OCEAN_KEY"
# {"ok":true,"items":[],"returned":0,"complete":true,"index":true,"next_cursor":null}
Key s!u exists — the full list above returns it, and
GET /v2/entity/_docs_probe/eucities/enc/s%21u returns {"k":"s!u"} —
but page two came back empty. Worse, it came back complete:true: the server
certified a page that had silently skipped a row. Sending the cursor as s+t
behaves identically. Sending a raw & is different again — it is read as a
parameter separator, so ?cursor=x&y returns
400 {"ok":false,"error":"unknown query parameter \"y\" — nothing was applied","code":"UNKNOWN_PARAM"}.
There is no client-side workaround today. Restrict keys you intend to page over to
A–Z a–z 0–9 - _ . ~. Keys outside that set are fine for
GET /v2/entity/…, which does decode correctly.
field + valueReturns only the keys in the route whose payload has field equal to
value, read out of a secondary index.
curl -s "https://api2.infiniteocean.io/v2/list/_docs_probe/eucities?field=country&value=NO" \
-H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,
"items":[
{"key":"city-003","ts":0,"value":{"name":"Oslo","country":"NO","pop":709037}},
{"key":"city-004","ts":0,"value":{"name":"Bergen","country":"NO","pop":286930}}],
"returned":2,"complete":true,"index":true,"next_cursor":null}
Four things to know before you rely on it:
[] with complete:true. That is a confident empty answer,
not an error, and it is indistinguishable from "no drop matches". Build the index per key
with POST /v2/index/{route}/{key} and no request body:
curl -s -X POST https://api2.infiniteocean.io/v2/index/_docs_probe/eucities/city-003 \
-H "X-Ocean-Key: $OCEAN_KEY" -H "Content-Length: 0"
# {"ok":true,"route":"_docs_probe/eucities","key":"city-003",
# "terms_indexed":7,"fields_indexed":3,"links_indexed":0,"nums_indexed":1}
If you do send a body, that body is indexed instead of the stored drop. Posting
{"field":"country"} returns 200 with
"fields_indexed":1 and leaves field=country&value=NO returning
nothing, because what got indexed was a document whose field is
"country". See the indexing page.ts is always 0 in filtered results. The
filtered path does not carry write timestamps. Do not sort or compare on it.index is always true on this path, whatever
the state of the route. Only complete carries information here.400 {"ok":false,"error":"field and value must both be set for a filtered list — nothing was applied","code":"PARTIAL_FILTER"}.limit and cursor page the filtered result the same way as an
unfiltered list, and the cursor is a key from the filtered page:
curl -s "https://api2.infiniteocean.io/v2/list/_docs_probe/eucities?field=country&value=PT&limit=1" \
-H "X-Ocean-Key: $OCEAN_KEY"
# {"ok":true,"items":[{"key":"city-001","ts":0,"value":{"name":"Lisbon",…}}],"returned":1,
# "complete":false,"index":true,"next_cursor":"city-001"}
curl -s "https://api2.infiniteocean.io/v2/list/_docs_probe/eucities?field=country&value=PT&limit=1&cursor=city-001" \
-H "X-Ocean-Key: $OCEAN_KEY"
# {"ok":true,"items":[{"key":"city-002","ts":0,"value":{"name":"Porto",…}}],"returned":1,
# "complete":true,"index":true,"next_cursor":null}
| Status | Code | When |
|---|---|---|
| 400 | BAD_LIMIT | limit=0, limit=101, limit=-1, limit=abc. Message: limit must be a positive integer within the allowed range — nothing was applied. |
| 400 | PARTIAL_FILTER | Only one of field/value. |
| 400 | INVALID_INPUT | field set with an empty value=. Message: invalid filter — nothing was applied. |
| 400 | UNKNOWN_PARAM | Any parameter other than the four above. |
| 401 | KEY_REQUIRED / UNKNOWN_KEY | Missing or unrecognised X-Ocean-Key. |
| 403 | FORBIDDEN | No access rule grants your key read on the route — including routes that do not exist. |
| 404 | NOT_FOUND | no /v2 endpoint matches this method and path — you used the wrong method (only GET is routed) or omitted the route, e.g. /v2/list/. |
Tells you how far a specific write has progressed. {seq} is the
seq integer returned by POST /v2/drop.
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/receipts","key":"life-pinned","payload":{"n":2}}'
# {"ok":true,"seq":1787577342970126,"offset":0,"len":84,"ts":1787594387682,
# "state":"accepted","rule_created":false,"drop_id":"1787577342970126",
# "route":"_docs_probe/receipts","key":"life-pinned"}
curl -s https://api2.infiniteocean.io/v2/receipt/1787577342970126 \
-H "X-Ocean-Key: $OCEAN_KEY"
# {"ok":true,"seq":1787577342970126,"state":"accepted","node":"hil"}
Polled every five seconds against the node that took the write, the same seq moved from
accepted to durable between the first and second poll and stayed
there:
+0s {"ok":true,"seq":1787577342970126,"state":"accepted","node":"hil"}
+5s {"ok":true,"seq":1787577342970126,"state":"durable","node":"hil"}
+11s {"ok":true,"seq":1787577342970126,"state":"durable","node":"hil"}
A receipt is not a permanent record. Once the write has settled, the entry is retired and
the endpoint returns 404 for that seq on every node. Two writes were polled every
five seconds from acceptance:
run 1 (polled through api2.infiniteocean.io, so the load balancer picks the node)
+0s accepted +5s durable … +375s durable +381s 404
Re-checked later against all five nodes with --resolve: 404 everywhere.
run 2 (polled with --resolve pinned to the accepting node)
+0s accepted +5s durable … still durable at +654s
Run 1's 404 at +381 s cannot be trusted as the moment of retirement — that
poll went through the load balancer and may have landed on a node that never held the receipt
at all. Run 2, pinned to the accepting node, was still answering durable at
+654 s. This page therefore gives no retention number. What is established
is that receipts do disappear, and that the seq from run 1 later returned 404 on
all five nodes.
So poll a receipt while a write is in flight; do not store a seq and expect to resolve it
later. A 404 from this endpoint is not evidence that a write failed
— it is also what a settled receipt and a non-accepting node both return.
| State | Meaning |
|---|---|
accepted | The write is on the accepting node's disk and will not be lost if that node restarts. It is not yet in object storage. |
durable | The write has reached object storage. This is the state to wait for if you are gating anything on durability, and the last state observed before a receipt retires. |
A third state, global, appears in the server's own vocabulary but was never
produced in any probe behind this page. Do not build a wait loop that blocks on it. Treat
durable as the terminal state you can rely on.
This endpoint refuses a request with no X-Ocean-Key (401
KEY_REQUIRED), but it does not resolve the key or check it against the write's owner.
An unrecognised key gets the same 404 NOT_FOUND as an unknown seq, not
401 UNKNOWN_KEY:
curl -s https://api2.infiniteocean.io/v2/receipt/999999999999 \
-H "X-Ocean-Key: not-a-real-key-…"
# 404 {"ok":false,"error":"not found","code":"NOT_FOUND"}
A receipt body carries only seq, state and node — no
route, key or payload.
This is the trap. The receipt is read out of the accepting node's local table. Ask any other node and you get a 404 that is indistinguishable from "that seq never existed". The same seq, queried against all five nodes at the same moment:
de -> 404 {"ok":false,"error":"not found","code":"NOT_FOUND"}
fi -> 404 {"ok":false,"error":"not found","code":"NOT_FOUND"}
ash -> 404 {"ok":false,"error":"not found","code":"NOT_FOUND"}
hil -> 200 {"ok":true,"seq":1787577342969761,"state":"durable","node":"hil"}
sin -> 404 {"ok":false,"error":"not found","code":"NOT_FOUND"}
api2.infiniteocean.io is load-balanced across those nodes and steers by
geography, so a plain curl poll will usually — but not always — reach the same
node twice. Practical guidance:
404 from a receipt poll as "unknown", never as
"lost" — it is also what a settled, retired receipt returns, and what any node other than
the accepting one returns. Retry rather than escalate.GET /v2/entity/{route}/{key} instead. It resolves on any node and does not
depend on which node took the write.| Status | Body | When |
|---|---|---|
| 404 | {"ok":false,"error":"not found","code":"NOT_FOUND"} | This node has no record of that seq. Four causes, indistinguishable from the response: the write settled and the receipt retired (most common), the write was accepted by a different node, the seq never existed, or the key you sent is not a key this server knows. |
| 400 | {"ok":false,"error":"path does not name a valid resource","code":"BAD_PATH"} | The path segment is not a decimal integer, e.g. /v2/receipt/abc. |
| 401 | {"ok":false,"error":"X-Ocean-Key required","code":"KEY_REQUIRED"} | No key header. |
| 400 | {"ok":false,"error":"unknown query parameter \"x\" — nothing was applied","code":"UNKNOWN_PARAM"} | This endpoint takes no query parameters. |
Lists the routes your key can reach, split into routes you own and routes shared with you.
curl -s https://api2.infiniteocean.io/v2/routes -H "X-Ocean-Key: $OCEAN_KEY"
Real response, abridged in the middle of mine only:
{"ok":true,
"mine":[
{"route":"'ft-quoted-probe'","op":"read-write","exp":0,"at":1787039643906},
{"route":"/_docs_probe/notes","op":"read-write","exp":0,"at":1787592109033},
{"route":"_docs_probe","op":"read-write","exp":0,"at":1787592330849},
… 42 more …
{"route":"_docs_probe/other","op":"read-write","exp":0,"at":1787593747149}],
"shared":[
{"route":"_docs_probe/access-share-exp","op":"read-write","exp":1000000000000,"at":1787593187991},
{"route":"_docs_probe/access-share-ro","op":"read","exp":0,"at":1787593178339},
{"route":"_docs_probe/access-share-rw","op":"read-write","exp":0,"at":1787593151217},
{"route":"rdx1786538154","op":"read","exp":0,"at":1786538300827}],
"mine_next":"_docs_probe/other",
"shared_next":null,
"mine_complete":false,
"shared_complete":false}
| Field | Meaning |
|---|---|
mine / shared | Arrays of {route, op, exp, at}. op is the grant — read or read-write. A shared route can be read-write; do not assume shared means read-only. exp is an expiry in ms, 0 meaning no expiry. at is when the grant was recorded, ms since epoch. |
mine_next / shared_next | The cursor for the next page of that side, or null. Unusable today — see below. |
mine_complete / shared_complete | Same meaning as complete on a list: whether the server can certify it showed you every route on that side. |
The response advertises a cursor, but the endpoint rejects every query parameter, so there is no way to send one back. All measured:
curl -s "https://api2.infiniteocean.io/v2/routes?limit=3" -H "X-Ocean-Key: $OCEAN_KEY"
# 400 {"ok":false,"error":"unknown query parameter \"limit\" — nothing was applied",
# "code":"UNKNOWN_PARAM"}
curl -s "https://api2.infiniteocean.io/v2/routes?cursor_mine=zzz" -H "X-Ocean-Key: $OCEAN_KEY"
# 400 {"ok":false,"error":"unknown query parameter \"cursor_mine\" — nothing was applied",
# "code":"UNKNOWN_PARAM"}
mine_cursor and cursor are rejected the same way.
So if you have more than 50 owned routes you will see at most the first 50 in key order and
no way to reach the rest through this endpoint. Note also that a short array does not mean the
end: in the run above mine contained 46 entries — under the cap of 50 — and
mine_next was still set and mine_complete was false. Do
not infer "that's all of them" from mine.length; read
mine_complete.
If you need the full set, track your own routes as you create them.
| Status | Body | When |
|---|---|---|
| 401 | {"ok":false,"error":"X-Ocean-Key required","code":"KEY_REQUIRED"} | No key header. |
| 401 | {"ok":false,"error":"unknown key — not authenticated","code":"UNKNOWN_KEY"} | Key not recognised. |
| 400 | {"ok":false,"error":"unknown query parameter \"…\" — nothing was applied","code":"UNKNOWN_PARAM"} | Any query parameter. |
The API runs on five nodes and Cloudflare steers you to the nearest one. Your write lands
on one node; your next read may be served by another. Until the write has propagated, that
other node returns 404 NOT_FOUND for the entity and omits it from a list.
Measured four times on one day — write on hil, then poll de once
a second until it returned the payload:
run 1: visible after 2,250 ms
run 2: visible after 2,411 ms
run 3: visible after 274 ms
run 4: visible after 2,223 ms
Those are the numbers from one client, one node pair, one afternoon, and the spread within those four runs is already a factor of nine. They are not a service level. Design for retry, not for a number. What to do about it:
POST /v2/drop returns 200 with
"state":"accepted".complete usually catches this, but not always. A node that
knows a peer has unfolded writes for the route says complete:false rather than
handing you a short list that looks authoritative. Across nine trials — write a key on
hil, then list the route on de immediately — the key was missing
from de's page eight times. Seven of those eight came back
complete:false. One came back complete:true. So
complete:true is not a guarantee that a write made moments ago on another node
is included. If you need to see a specific write, read that key directly and retry.global is named by the server but was never produced in
these probes — every polled write went accepted then durable and
reported durable on every subsequent poll.404 arrived at +381 s but was load-balancer-sampled and so may have hit a node
that never held the receipt; a second run, pinned to the accepting node, was still answering
durable at +654 s. Do not read a number into "receipts expire".complete are all timing observations from a handful of runs on
one day. None of them is a guarantee.complete:true while omitting a key
written seconds earlier on another node was not reproduced in the other seven missing-key
trials. It is reported because it happened, not because its conditions are understood.403 FORBIDDEN behaviour was measured with a key that owns the route and
with a route that has no rule at all. Read-only shares, app-scoped keys and expired grants
were not exercised against these endpoints; see the access-control page.https://api2.infiniteocean.io · authenticate with the X-Ocean-Key header.