InfiniteOceanv2

Access control

How routes are owned, how ownership is claimed by the first write, how to grant and revoke access to another account, and how account keys differ from the scoped app/sub keys you hand to applications — with every refusal, every timing figure and every non-nesting rule demonstrated against the live API.

The model

Everything you store in InfiniteOcean lives on a route — a slash-separated name like myapp/users. Every route has exactly one owner, and access to it is decided by a single rule attached to that route.

Four facts carry the whole model:

Everything on this page was executed against https://api2.infiniteocean.io and the responses are pasted verbatim. The account used had public id 9a92e0aed98f and handle ftft1787595422h; those appear in the real responses below. Probe routes live under _docs_probe/v1787599144/.

None of the endpoints on this page accept any query parameter. Sending one is a hard 400 and nothing is applied:

curl -s "https://api2.infiniteocean.io/v2/access/_docs_probe/v1787599144/demo?expand=true" \
  -H "X-Ocean-Key: $OCEAN_KEY"
400
{"ok":false,"error":"unknown query parameter \"expand\" — nothing was applied","code":"UNKNOWN_PARAM"}

Public ids and handles

An account is identified to other people by its public id — 12 lowercase hex characters, for example 9a92e0aed98f. This is the value you hand someone so they can grant you access, and the value you put in a share. It is not a secret and confers nothing on its own.

An account may also claim a handle, which is an alias for the same public id and can be used in place of it when granting access. Ask the door who you are:

curl -s https://api2.infiniteocean.io/v2/whoami \
  -H "X-Ocean-Key: $OCEAN_KEY"
200
{"ok":true,"pid":"9a92e0aed98f","handle":"ftft1787595422h","key_kind":"account"}

key_kind tells you which kind of key you are holding — account or app_key. With an app key the same call answers differently and shows you the scope you are confined to — see Account keys, app keys and sub keys.

Who owns a route

Ownership is claimed by the first write

Before anyone has written to a route there is no rule, and asking about it says so:

curl -s https://api2.infiniteocean.io/v2/access/_docs_probe/v1787599144/demo \
  -H "X-Ocean-Key: $OCEAN_KEY"
404
{"ok":false,"error":"no access rule on this route","code":"NO_RULE"}

Write to it, and the claim happens as a side effect of the write. The response carries rule_created, which is how you tell "I just took ownership of this route" from "I wrote to a route I already had":

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/v1787599144/demo","key":"hello","payload":{"msg":"first write"}}'
200
{"ok":true,"seq":1787577342970899,"offset":0,"len":100,"ts":1787599149886,"state":"accepted",
 "rule_created":true,"drop_id":"1787577342970899","route":"_docs_probe/v1787599144/demo","key":"hello"}

Every later write to that route reports rule_created:false:

200
{"ok":true,"seq":1787577342970901,"offset":0,"len":101,"ts":1787599150156,"state":"accepted",
 "rule_created":false,"drop_id":"1787577342970901","route":"_docs_probe/v1787599144/demo","key":"hello"}

Treat rule_created:true as the only proof that a route is yours. offset is per node and carries no meaning about ordering across the fleet — the two writes above returned offset:0 twice because they were served by different nodes.

GET /v2/access/{route}

Reads the rule on a route: its owner, whether it is enforced, and every share on it.

Requires: a key with read access to the route — the owner, or anyone holding a share that permits read. Anyone you grant read to can see the full share list, including who else you have granted access to. Verified from the receiving side: a key that holds only a read share, and does not own the route, reads the whole rule:

curl -s https://api2.infiniteocean.io/v2/access/_docs_probe/v1787599144/handover \
  -H "X-Ocean-Key: $OCEAN_KEY"
200
{"ok":true,"route":"_docs_probe/v1787599144/handover","owner":"aaaaaaaaaaaa","owner_handle":null,
 "enforced":true,"shares":[{"pid":"9a92e0aed98f","handle":"ftft1787595422h","perm":"read",
 "expires_at_ms":0,"expired":false}]}

A key with no standing on the route gets a 403, not a 404 — the door distinguishes "you may not look" from "there is nothing there":

403
{"ok":false,"error":"authenticated key is not a principal on this route","code":"NOT_PRINCIPAL"}

Fields:

Rules do not nest — this is the one most likely to bite you

A rule applies to its exact route string and to nothing else. There is no inheritance in either direction, and both directions were demonstrated live.

Owning a parent gives you nothing on a child. Here _docs_probe/v1787599144/demo is owned by this key, and its child _docs_probe/v1787599144/demo/sub is owned by aaaaaaaaaaaa with no share. Every door on the child refuses the parent's owner:

# read the child's data
curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/v1787599144/demo/sub/x \
  -H "X-Ocean-Key: $OCEAN_KEY"
403
{"ok":false,"error":"authenticated key is not a principal on this route","code":"NOT_PRINCIPAL"}

# read the child's rule
curl -s https://api2.infiniteocean.io/v2/access/_docs_probe/v1787599144/demo/sub \
  -H "X-Ocean-Key: $OCEAN_KEY"
403
{"ok":false,"error":"authenticated key is not a principal on this route","code":"NOT_PRINCIPAL"}

# write to the child
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/v1787599144/demo/sub","key":"x","payload":{"a":1}}'
403
{"ok":false,"error":"authenticated key is not a principal on this route","code":"NOT_PRINCIPAL"}

A child of someone else's route is an unclaimed route, and you can claim it. Continuing from above — demo/sub belongs to aaaaaaaaaaaa and this key cannot even read its rule, yet the route beneath it is free:

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/v1787599144/demo/sub/deeper","key":"x","payload":{"msg":"child of a foreign route"}}'
200
{"ok":true,"seq":1787577342971091,"offset":0,"len":120,"ts":1787600331902,"state":"accepted",
 "rule_created":true,"drop_id":"1787577342971091","route":"_docs_probe/v1787599144/demo/sub/deeper","key":"x"}
curl -s https://api2.infiniteocean.io/v2/access/_docs_probe/v1787599144/demo/sub/deeper -H "X-Ocean-Key: $OCEAN_KEY"
200
{"ok":true,"route":"_docs_probe/v1787599144/demo/sub/deeper","owner":"9a92e0aed98f",
 "owner_handle":"ftft1787595422h","enforced":true,"shares":[]}

Owning myapp does not reserve the myapp/ namespace. If you want a subtree to be yours, write one drop to each route in it — that claim is what makes it yours.

Reserved routes you cannot claim

Some routes belong to the platform and are refused before the claim is attempted:

curl -s -X POST https://api2.infiniteocean.io/v2/drop \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"route":"__acl__/rules","key":"x","payload":{"a":1}}'
403
{"ok":false,"error":"platform route is not claimable","code":"RESERVED_SYSTEM_ROUTE"}

The reserved set is: anything beginning with __, plus _auth, _sites and _config. Those three are matched as raw string prefixes, not as whole path segments — so they swallow ordinary-looking names that merely start with the same letters. This surprises people, so it is worth seeing:

POST /v2/drop  route "_authors/mine"    →  403  RESERVED_SYSTEM_ROUTE
POST /v2/drop  route "_sitesmap/mine"   →  403  RESERVED_SYSTEM_ROUTE
POST /v2/drop  route "_sitemapx/mine"   →  200  rule_created:true

A single leading underscore is otherwise yours to use — _docs_probe throughout this page is a customer route. But before you settle on a name starting with _a, _s or _c, write one drop to it and check you got a 200.

Granting access to another party

PUT /v2/access/{route}/shares/{principal}

Grants another account access to one route. Only the route's owner may call it.

Principal (in the path) is one of:

Body: perm is required and must be exactly "read" or "read-write". expires_at_ms is optional. No other field is accepted.

curl -s -X PUT \
  https://api2.infiniteocean.io/v2/access/_docs_probe/v1787599144/demo/shares/aaaaaaaaaaaa \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"perm":"read"}'
200
{"ok":true,"route":"_docs_probe/v1787599144/demo","pid":"aaaaaaaaaaaa","perm":"read","changed":true}

The rule now shows it:

200
{"ok":true,"route":"_docs_probe/v1787599144/demo","owner":"9a92e0aed98f","owner_handle":"ftft1787595422h",
 "enforced":true,"shares":[{"pid":"aaaaaaaaaaaa","perm":"read","expires_at_ms":0,"expired":false}]}

The call is idempotent and upgrades in place. Re-issuing with a different perm replaces the old one; re-issuing with the same perm returns changed:false and applies nothing:

-d '{"perm":"read-write"}'  →  {"ok":true,...,"perm":"read-write","changed":true}
-d '{"perm":"read-write"}'  →  {"ok":true,...,"perm":"read-write","changed":false}

Granting by handle resolves to the public id at grant time, and the stored share is the pid:

curl -s -X PUT \
  https://api2.infiniteocean.io/v2/access/_docs_probe/v1787599144/other/shares/@ftft1787595422h \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"perm":"read"}'
200
{"ok":true,"route":"_docs_probe/v1787599144/other","pid":"9a92e0aed98f","perm":"read","changed":true}

Because the handle is resolved once and the pid is stored, a later handle change does not move the grant. Re-issuing the same grant without the @ hits the same stored share and reports changed:false. The share list reports both, so you can see who it really points at:

"shares":[{"pid":"9a92e0aed98f","handle":"ftft1787595422h","perm":"read",
           "expires_at_ms":0,"expired":false}]

A grant really does confer access — and its absence really does deny

Checked from the receiving side. Two routes were created and then handed to another owner; one carried a read share for this key, the other carried nothing. Same key, all four requests:

# route with a read share for this key
curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/v1787599144/handover/doc \
  -H "X-Ocean-Key: $OCEAN_KEY"
200
{"msg":"shared with me"}

# the list door on the same route, same share
curl -s https://api2.infiniteocean.io/v2/list/_docs_probe/v1787599144/handover \
  -H "X-Ocean-Key: $OCEAN_KEY"
200
{"ok":true,"items":[{"key":"doc","ts":1787599363094,"value":{"msg":"shared with me"}}],
 "returned":1,"complete":true,"index":true,"next_cursor":null}

# a write, against a share that only permits read
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/v1787599144/handover","key":"doc","payload":{"msg":"should be refused"}}'
403
{"ok":false,"error":"authenticated key is not a principal on this route","code":"NOT_PRINCIPAL"}

# route with no share for this key
curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/v1787599144/handover2/doc \
  -H "X-Ocean-Key: $OCEAN_KEY"
403
{"ok":false,"error":"authenticated key is not a principal on this route","code":"NOT_PRINCIPAL"}

NOT_PRINCIPAL is a 403, not a 401: the key is valid and authenticated, it simply has no standing on that route. Do not treat it as a credential problem.

Time-limited grants

expires_at_ms is a Unix epoch timestamp in milliseconds. 0 means never.

curl -s -X PUT \
  https://api2.infiniteocean.io/v2/access/_docs_probe/v1787599144/demo/shares/bbbbbbbbbbbb \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"perm":"read","expires_at_ms":1788000000000}'
200
{"ok":true,"route":"_docs_probe/v1787599144/demo","pid":"bbbbbbbbbbbb","perm":"read","changed":true}

Omitting the field on a re-grant preserves the expiry the share already had. It does not reset to never. Changing the permission on a share that expires therefore leaves it expiring:

# before
{"pid":"bbbbbbbbbbbb","perm":"read","expires_at_ms":1788000000000,"expired":false}

curl -s -X PUT https://api2.infiniteocean.io/v2/access/_docs_probe/v1787599144/demo/shares/bbbbbbbbbbbb \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"perm":"read-write"}'
200
{"ok":true,"route":"_docs_probe/v1787599144/demo","pid":"bbbbbbbbbbbb","perm":"read-write","changed":true}

# after — the expiry survived the permission change
{"pid":"bbbbbbbbbbbb","perm":"read-write","expires_at_ms":1788000000000,"expired":false}

To make a share permanent you must send 0 explicitly:

-d '{"perm":"read-write","expires_at_ms":0}'
200
{"ok":true,"route":"_docs_probe/v1787599144/demo","pid":"bbbbbbbbbbbb","perm":"read-write","changed":true}

{"pid":"bbbbbbbbbbbb","perm":"read-write","expires_at_ms":0,"expired":false}

Expired shares stay in the rule and are marked rather than removed, so you can see that a grant lapsed instead of wondering whether it was ever made:

"shares":[{"pid":"aaaaaaaaaaaa","perm":"read-write","expires_at_ms":0,"expired":false},
          {"pid":"bbbbbbbbbbbb","perm":"read-write","expires_at_ms":0,"expired":false},
          {"pid":"cccccccccccc","perm":"read","expires_at_ms":1000000000000,"expired":true}]

Note that expired is computed by this door only. GET /v2/routes reports the same grant with a past exp and no expiry marker at all — see Listing what you can reach.

Publishing a route with @view

Sharing to the literal principal @view is not a grant to an account. It is the publishing switch: it is the mechanism by which a route's contents are served to people who hold no key.

Treat a route with a @view share as public, and put nothing on it you would not put on a web page. The grant side is verified below — the share is accepted, recorded and removable. What is not verified is the serving side: a published probe route fetched anonymously from the public host returned 504 SITES_ORIGIN_TIMEOUT, so this page cannot show you a public read succeeding, and does not name a public URL, propagation delay or cache behaviour. The warning above is therefore the conservative one: assume it publishes, because that is what the switch is for.

curl -s -X PUT \
  https://api2.infiniteocean.io/v2/access/_docs_probe/v1787599144/other/shares/@view \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"perm":"read"}'
200
{"ok":true,"route":"_docs_probe/v1787599144/other","pid":"@view","perm":"read","changed":true}

It appears in the rule alongside real grants, distinguishable because its pid is the literal string @view rather than 12 hex characters:

"shares":[{"pid":"9a92e0aed98f","handle":"ftft1787595422h","perm":"read","expires_at_ms":0,"expired":false},
          {"pid":"@view","perm":"read","expires_at_ms":0,"expired":false}]

Removing the share unpublishes it, and returns the ordinary share-removal response:

curl -s -X DELETE \
  https://api2.infiniteocean.io/v2/access/_docs_probe/v1787599144/other/shares/@view \
  -H "X-Ocean-Key: $OCEAN_KEY"
200
{"ok":true,"route":"_docs_probe/v1787599144/other","share_removed":true,"changed":true}

This door has two failure codes of its own, PUBLISH_UNAVAILABLE (503) and PUBLISH_FAILED (502), both of which mean the @view share was not recorded. Neither could be induced here, so do not rely on their exact shape — rely on the status code being non-200.

DELETE /v2/access/{route}/shares/{principal}

Revokes a grant. Owner only. Accepts the same three principal forms as the grant.

Removing a share that was not there is also a 200, and share_removed is true in both cases — check changed, not the status code and not share_removed, if you need to know whether a revocation actually did something:

curl -s -X DELETE \
  https://api2.infiniteocean.io/v2/access/_docs_probe/v1787599144/other/shares/eeeeeeeeeeee \
  -H "X-Ocean-Key: $OCEAN_KEY"
200
{"ok":true,"route":"_docs_probe/v1787599144/other","share_removed":true,"changed":false}

Transferring ownership

PUT /v2/access/{route}/owner

Hands a route to another account. Only the current owner may call it, and it is one-way: once it lands you are not the owner and cannot take it back, or even change the rule again, unless the new owner grants you something.

Body: new_owner_pid, exactly 12 lowercase hex characters. (public_id is accepted as an alias for the same field — both forms were used successfully.) Existing shares are carried across unchanged, and the outgoing owner is not automatically given one — if you want to keep access, grant yourself a share before you transfer.

curl -s -X PUT \
  https://api2.infiniteocean.io/v2/access/_docs_probe/v1787599144/handover/owner \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"new_owner_pid":"aaaaaaaaaaaa"}'
200
{"ok":true,"route":"_docs_probe/v1787599144/handover","owner":true,"changed":true}

The rule afterwards — new owner, share carried across:

200
{"ok":true,"route":"_docs_probe/v1787599144/handover","owner":"aaaaaaaaaaaa","owner_handle":null,
 "enforced":true,"shares":[{"pid":"9a92e0aed98f","handle":"ftft1787595422h","perm":"read",
 "expires_at_ms":0,"expired":false}]}

And the former owner can no longer touch the rule:

curl -s -X PUT \
  https://api2.infiniteocean.io/v2/access/_docs_probe/v1787599144/handover/shares/dddddddddddd \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" -d '{"perm":"read"}'
403
{"ok":false,"error":"only the route owner may change this — nothing was applied","code":"FORBIDDEN"}

Transferring to the pid that already owns the route returns changed:false and applies nothing. A new_owner_pid that is not 12 lowercase hex is a 400 INVALID_PUBLIC_ID, and an extra body field is a 400 UNKNOWN_FIELD; in both cases nothing is applied.

The enforced flag

PUT /v2/access/{route}/enforced

Read this before you use it. enforced:false does not make a route public and does not "turn off" access control. It makes the route unreachable by everyone, including its owner, and makes the rule read as if it did not exist.

Body: enforced, a JSON boolean. A string "true" is refused with 400 MISSING_FIELD — the door treats a wrong type as an absent field.

curl -s -X PUT \
  https://api2.infiniteocean.io/v2/access/_docs_probe/v1787599144/enf/enforced \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"enforced":false}'
200
{"ok":true,"route":"_docs_probe/v1787599144/enf","enforced":false,"changed":true}

With that set, the owner's own read and own write are both refused, and the rule reports as missing:

curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/v1787599144/enf/hello -H "X-Ocean-Key: $OCEAN_KEY"
403
{"ok":false,"error":"forbidden","code":"FORBIDDEN"}

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/v1787599144/enf","key":"hello","payload":{"msg":"write while unenforced"}}'
403
{"ok":false,"error":"forbidden","code":"FORBIDDEN"}

curl -s https://api2.infiniteocean.io/v2/access/_docs_probe/v1787599144/enf -H "X-Ocean-Key: $OCEAN_KEY"
404
{"ok":false,"error":"no access rule on this route","code":"NO_RULE"}

The write being refused rather than re-claiming the route is the important part: an unenforced route is not an unclaimed one for its owner. Whether a different account could claim it while the rule reads as missing was not tested here.

The route is not lost. This door checks ownership directly rather than going through the access decision, so the owner can always set it back — and the data reads normally again:

curl -s -X PUT https://api2.infiniteocean.io/v2/access/_docs_probe/v1787599144/enf/enforced \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" -d '{"enforced":true}'
200
{"ok":true,"route":"_docs_probe/v1787599144/enf","enforced":true,"changed":true}

curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/v1787599144/enf/hello -H "X-Ocean-Key: $OCEAN_KEY"
200
{"msg":"enf test"}

In normal use you never set this. Routes are created with enforced:true and should stay there.

Account keys, app keys and sub keys

There are two kinds of credential, and both travel in the same X-Ocean-Key header. What differs is what they can reach.

You cannot tell them apart by length. The account key measured here is 67 characters; an app key secret is ak_ plus 64 hex characters, which is also 67. The prefix is the only distinguishing feature, and GET /v2/whoami is the reliable test.

 Account keyApp key / sub key
Looks like67 characters, no prefixak_ + 64 hex characters
Identityis the accountacts as the account, inside a scope
Where it comes fromPOST /v2/auth/request, then the emailed code or linkPOST /v2/appkeys or POST /v2/subkeys
Read/write dropsevery route the account owns or is sharedonly routes matching its routes patterns, and only the ops its can_read/can_write allow
Claim a new routeyesyes, but only inside its own scope and only with can_write
Read a rule (GET /v2/access)yes, where it has read accessyes, but only within scope
Change a rule (share / owner / enforced)yes, where it ownsno — 403 FORBIDDEN, on all four doors
Mint or list keysyesno — 401 KEY_REQUIRED
Revoke a keyyesno, but it looks like yes — 200 changed:false. See the warning below.
List routes (GET /v2/routes)yesno — 401 UNKNOWN_KEY
Rotate the account keyPOST /v2/keys/rotateno — 400 APPKEY_USE_REVOKE

Every "no" in that table was produced live. The rule-change row covers all four mutation doors — share PUT, share DELETE, owner and enforced — each of which returned 403 FORBIDDEN to an app key that was in scope for the route.

The revoke row is the trap. An app key is not rejected at DELETE /v2/appkeys/{id}. It is accepted and answers 200 {"ok":true,...,"changed":false} — the same body a successful-but-idempotent revoke produces. It does not actually revoke anything:

# app key aimed at a LIVE key belonging to the same account
curl -s -X DELETE https://api2.infiniteocean.io/v2/appkeys/b0fd30de8482 -H "X-Ocean-Key: $APP_KEY"
200
{"ok":true,"id":"b0fd30de8482","changed":false}

# the target key still worked afterwards
curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/v1787599144/demo/hello -H "X-Ocean-Key: $VICTIM"
200
{"msg":"second write"}

# the same app key could not even revoke itself
curl -s -X DELETE https://api2.infiniteocean.io/v2/appkeys/6673e1c6e929 -H "X-Ocean-Key: $APP_KEY"
200
{"ok":true,"id":"6673e1c6e929","changed":false}

# and kept working
curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/v1787599144/demo/hello -H "X-Ocean-Key: $APP_KEY"
200
{"msg":"second write"}

So there is no privilege escalation here — but if you build a revoke tool and run it with an app key, it will report success and revoke nothing. Use an account key, and check changed.

/v2/subkeys and /v2/appkeys are the same thing. Both mint the same kind of credential into the same store, and the two listings returned byte-identical JSON (218 entries on the account used here). The only real difference is that DELETE /v2/subkeys/{…} also accepts a key's name, while DELETE /v2/appkeys/{name} is a 400 BAD_PATH. Pick one path and stay on it.

An app key never exceeds the account that minted it. Scope narrows; it cannot widen. It also inherits: an account-wide (routes:["*"]) key read a route the account merely holds a read share on, and was refused on a route the account had transferred away:

# route the account holds a read share on
GET /v2/entity/_docs_probe/v1787599144/handover/doc    →  200  {"msg":"shared with me"}

# route the account transferred away with no share
GET /v2/entity/_docs_probe/v1787599144/handover2/doc   →  403  {"ok":false,"error":"forbidden","code":"FORBIDDEN"}
GET /v2/access/_docs_probe/v1787599144/handover2       →  403  {"ok":false,"error":"forbidden","code":"FORBIDDEN"}

Note the code difference on that last route: an account key gets NOT_PRINCIPAL there, an app key gets FORBIDDEN. Same situation, two codes, depending on which kind of key asked.

Minting a scoped key

POST /v2/appkeys

Mints a key confined to a set of routes. Account key only.

Required: name (a string, for your own bookkeeping and for revoke-by-name) and routes (a non-empty array of strings). Omitting either, or sending routes:[], is a 400 MISSING_FIELD.

Optional: can_read and can_write, both booleans. Both default to true. A key minted without them can write.

curl -s -X POST https://api2.infiniteocean.io/v2/appkeys \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"name":"docs-demo-reader","routes":["_docs_probe/v1787599144/demo"],"can_read":true,"can_write":false}'
200
{"ok":true,"id":"6673e1c6e929","name":"docs-demo-reader","secret":"ak_<64 hex characters>",
 "secret_once":true,
 "warning":"this is the only time the secret is returned — store it; revoke is by id only"}

The secret is returned exactly once and is never retrievable again — the listing never includes a secret field. The id is not the secret; it is a short public fingerprint used for listing and revoking. If you lose the secret, revoke the id and mint a new one.

Reading the key back confirms what it is:

curl -s https://api2.infiniteocean.io/v2/whoami -H "X-Ocean-Key: $APP_KEY"
200
{"ok":true,"pid":"9a92e0aed98f","key_kind":"app_key","app_key":{"id":"6673e1c6e929",
 "name":"docs-demo-reader","routes":["_docs_probe/v1787599144/demo"],"can_read":true,"can_write":false},
 "scope":{"routes":["_docs_probe/v1787599144/demo"]}}

Two traps in the mint body

permissions is accepted and silently ignored. It passes the field check but sets nothing, so a key minted with "permissions":["read"] gets the defaults — read and write. Checked by minting one, reading it back, and writing with it:

curl -s -X POST https://api2.infiniteocean.io/v2/appkeys \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"name":"docs-demo-permtrap","routes":["_docs_probe/v1787599144/other"],"permissions":["read"]}'
200   (minted id 1d789c5df3ab)

curl -s https://api2.infiniteocean.io/v2/whoami -H "X-Ocean-Key: $MINTED"
200
{"ok":true,"pid":"9a92e0aed98f","key_kind":"app_key","app_key":{"id":"1d789c5df3ab",
 "name":"docs-demo-permtrap","routes":["_docs_probe/v1787599144/other"],"can_read":true,"can_write":true},
 "scope":{"routes":["_docs_probe/v1787599144/other"]}}

curl -s -X POST https://api2.infiniteocean.io/v2/drop -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $MINTED" \
  -d '{"route":"_docs_probe/v1787599144/other","key":"permtrap","payload":{"msg":"permissions did nothing"}}'
200
{"ok":true,"seq":1787577342971022,...,"route":"_docs_probe/v1787599144/other","key":"permtrap"}

Use can_read and can_write. Nothing else scopes operations. After minting, call GET /v2/whoami with the new key and read app_key.can_write back before you hand the key over — that is a two-second check that the key is what you meant.

Every other unknown field is a hard 400, including ones that sound plausible:

-d '{"name":"x","routes":["a"],"expires_at_ms":123}'
400
{"ok":false,"error":"unknown body field — nothing was applied","code":"UNKNOWN_FIELD"}

There is no expiry on an app key. Revoking is the only way to end one.

How routes is matched

Each entry is one of three forms, matched against the whole route string:

All three forms were checked live. A key scoped exactly to _docs_probe/v1787599144/demo — note that it does not reach the child:

GET /v2/entity/_docs_probe/v1787599144/demo/hello   →  200  {"msg":"second write"}
GET /v2/entity/_docs_probe/v1787599144/demo/sub/x   →  403  APPKEY_OUT_OF_SCOPE
GET /v2/entity/_docs_probe/v1787599144/other/x      →  403  APPKEY_OUT_OF_SCOPE

A key scoped to _docs_probe/v1787599144/demo/* — note that it does reach the parent:

GET /v2/entity/_docs_probe/v1787599144/demo/sub/x   →  200  {"msg":"child"}
GET /v2/entity/_docs_probe/v1787599144/demo/hello   →  200  {"msg":"second write"}   (parent included)
GET /v2/entity/_docs_probe/v1787599144/other/x      →  403  APPKEY_OUT_OF_SCOPE

A read-only key scoped to * read two unrelated routes, and was still refused the write:

GET  /v2/entity/_docs_probe/v1787599144/demo/hello  →  200  {"msg":"second write"}
GET  /v2/entity/_docs_probe/v1787599144/other/x     →  200  {"msg":"other route"}
POST /v2/drop  (route _docs_probe/v1787599144/other) →  403  APPKEY_OUT_OF_SCOPE

A key matches if any of its patterns match. One minted with the two exact routes reached both and nothing else:

GET /v2/entity/_docs_probe/v1787599144/demo/hello   →  200  {"msg":"second write"}
GET /v2/entity/_docs_probe/v1787599144/other/x      →  200  {"msg":"other route"}
GET /v2/entity/_docs_probe/v1787599144/demo/sub/x   →  403  APPKEY_OUT_OF_SCOPE

There is no partial-segment wildcard. A * anywhere other than as the whole trailing segment is a literal character in a route name. Minting still succeeds, so the mistake is silent until the key is used. Both placements were tested and both matched nothing:

curl -s -X POST https://api2.infiniteocean.io/v2/appkeys -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"name":"docs-demo-partial","routes":["_docs_probe/v1787599144/d*"],"can_write":false}'
200   (minted, id 4b685f2ed7d2)

curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/v1787599144/demo/hello -H "X-Ocean-Key: $MINTED"
403
{"ok":false,"error":"app key out of scope for this route or op","code":"APPKEY_OUT_OF_SCOPE"}

# same result for a leading partial, routes:["_docs_probe/v1787599144/*emo"]
403  APPKEY_OUT_OF_SCOPE

Only * alone and a trailing /* are wildcards. Since exact patterns do not cover children, a key meant to serve an application usually wants ["myapp/*"], not ["myapp"].

Scoped keys and new routes

An app key can claim a route that does not exist yet, but only one that its own scope already covers, and only if can_write is true. Inside scope:

curl -s -X POST https://api2.infiniteocean.io/v2/drop -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $APP_KEY" \
  -d '{"route":"_docs_probe/v1787599144/demo/brandnew","key":"x","payload":{"msg":"claim attempt"}}'
200
{"ok":true,"seq":1787577342971025,"offset":0,"len":107,"ts":1787599735882,"state":"accepted",
 "rule_created":true,"drop_id":"1787577342971025","route":"_docs_probe/v1787599144/demo/brandnew","key":"x"}

The new route is owned by the account, not by the key — confirmed by reading the rule with the account key, which showed owner:"9a92e0aed98f" and shares:[].

Outside scope, the same request is refused — and the code is misleading:

curl -s -X POST https://api2.infiniteocean.io/v2/drop -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $APP_KEY" \
  -d '{"route":"_docs_probe/v1787599144/escape2","key":"x","payload":{"msg":"escape attempt"}}'
401
{"ok":false,"error":"unknown key — not authenticated","code":"UNKNOWN_KEY"}

That 401 does not mean your key is invalid, and nothing was claimed — a follow-up GET /v2/access on that route with the account key returned 404 NO_RULE. It is what an app key gets when it tries to create a route it is not scoped for. On a route that already exists you get the clearer 403 APPKEY_OUT_OF_SCOPE. If a working app key suddenly returns UNKNOWN_KEY on a write, check whether the route is new and outside its patterns before you suspect the credential.

GET /v2/appkeys

Lists every key the account has minted, revoked ones included. Account key only. Secrets are never returned.

This call is slow, and it gets slower every time you mint. On the account used here — 218 keys, of which 203 were already revoked — it took 62 to 71 seconds across three runs and returned 34 KB. That is longer than the default timeout of most HTTP clients and longer than many reverse proxies allow. Set an explicit timeout of at least 120 seconds, do not put this call on a request path, and cache the result.

curl -s -m 120 https://api2.infiniteocean.io/v2/appkeys \
  -H "X-Ocean-Key: $OCEAN_KEY"
200   (measured: 62.6s, 34658 bytes, 218 entries)
{"ok":true,"keys":[
  {"id":"6673e1c6e929","name":"docs-demo-reader","routes":["_docs_probe/v1787599144/demo"],
   "can_read":true,"can_write":false,"revoked":false,"created_at_ms":1787599455494},
  …
]}

Each entry has exactly these fields: id, name, routes, can_read, can_write, revoked, created_at_ms. There is no filter and no pagination — the whole list comes back, and revoked keys stay in it forever. Filter on revoked client-side if you want live keys only.

DELETE /v2/appkeys/{id}

Revokes a key by the id from the mint response or the listing. Account key only. Revocation takes effect on the next request the key makes — the read below was the very next request after the revoke returned.

curl -s -X DELETE https://api2.infiniteocean.io/v2/appkeys/6673e1c6e929 \
  -H "X-Ocean-Key: $OCEAN_KEY"
200   (measured: 3.9s)
{"ok":true,"id":"6673e1c6e929","changed":true}

The revoked key is then refused on the route it was scoped for, with a code that says withdrawn rather than unknown:

curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/v1787599144/demo/hello \
  -H "X-Ocean-Key: $REVOKED_KEY"
403
{"ok":false,"error":"app key revoked","code":"APPKEY_REVOKED"}

Revoking an id that is already revoked, or does not exist, returns 200 with changed:false. The status code does not tell you whether anything happened — changed does.

DELETE /v2/appkeys/6673e1c6e929  (again)  →  200  {"ok":true,"id":"6673e1c6e929","changed":false}
DELETE /v2/appkeys/000000000000           →  200  {"ok":true,"id":"000000000000","changed":false}
DELETE /v2/appkeys/zzzzzzzzzzzz           →  200  {"ok":true,"id":"zzzzzzzzzzzz","changed":false}
DELETE /v2/appkeys/short                  →  400  {"ok":false,"error":"path does not name a valid
                                                   resource","code":"BAD_PATH"}

The path check is length, not hex. Twelve characters of any kind are accepted as an id and answered with changed:false; only a length other than 12 gives BAD_PATH. A typo that happens to keep the length will therefore look like an already-done revocation rather than an error. Compare the id you sent against the id echoed back, and check changed.

DELETE /v2/subkeys/{id or name}

Same revocation, but this path also accepts the name you minted the key with. Anything that is not 12 characters is treated as a name.

curl -s -m 200 -X DELETE https://api2.infiniteocean.io/v2/subkeys/docs-demo-named \
  -H "X-Ocean-Key: $OCEAN_KEY"
200   (measured: 73.5s)
{"ok":true,"id":"3e9ae6a99dce","changed":true}

The resolved id comes back so you can log what was actually revoked. A name that matches nothing returns id:null and changed:false:

200   (measured: 71.3s)
{"ok":true,"id":null,"name":"no-such-name-verify","changed":false}

Revoke by name is slow whether or not the name matches. Measured on the 218-key account above: a matching name took 73.5s, a non-matching name 71.3s. Revoking the same key by id took 3.9s. Name resolution walks the whole key list first, so a match buys you nothing. If you are revoking under time pressure — a leaked key — use the id, and set a timeout well above 90 seconds if you use a name at all.

Listing what you can reach

GET /v2/routes

A catalogue of routes this account owns and routes other accounts have shared with it. Account key only — an app key gets 401 UNKNOWN_KEY here, which is worth knowing because it reads like a bad credential.

curl -s https://api2.infiniteocean.io/v2/routes \
  -H "X-Ocean-Key: $OCEAN_KEY"
200
{"ok":true,
 "mine":[{"route":"_docs_probe","op":"read-write","exp":0,"at":1787592330849}, … 44 entries],
 "shared":[{"route":"_docs_probe/v1787599144/handover","op":"read","exp":0,"at":1787599369148},
           {"route":"_docs_probe/access-share-exp","op":"read-write","exp":1000000000000,"at":1787593187991},
           … 7 entries],
 "mine_next":"_docs_probe/h41787594006","shared_next":null,
 "mine_complete":false,"shared_complete":false}

mine is routes you own; shared is routes granted to you, with the op you were granted and exp the expiry in epoch ms (0 = never).

Do not use this endpoint as an inventory. Three measured reasons:

curl -s "https://api2.infiniteocean.io/v2/routes?cursor=_docs_probe/h41787594006" \
  -H "X-Ocean-Key: $OCEAN_KEY"
400
{"ok":false,"error":"unknown query parameter \"cursor\" — nothing was applied","code":"UNKNOWN_PARAM"}

There is also no expiry marker here. One shared entry carried exp:1000000000000 — a timestamp in the past — with nothing to say it had lapsed, where GET /v2/access marks the same grant "expired":true. Compare exp against the clock yourself.

GET /v2/access/{route} is authoritative for any route you can name. This endpoint is a hint.

Refusals you should expect

These are the responses a correctly scoped key produces when it strays. Each row was produced live during the writing of this page.

SituationStatusCode
No X-Ocean-Key header401KEY_REQUIRED
Key that does not exist401UNKNOWN_KEY
App key writing to a new route outside its scope401UNKNOWN_KEY
App key on GET /v2/routes401UNKNOWN_KEY
Valid account key, no standing on the route403NOT_PRINCIPAL
Read share used for a write403NOT_PRINCIPAL
Owner of a parent, reading or writing a child route403NOT_PRINCIPAL
App key on a route outside its routes403APPKEY_OUT_OF_SCOPE
App key doing an op its can_read/can_write forbids403APPKEY_OUT_OF_SCOPE
App key reading the rule of an out-of-scope route403APPKEY_OUT_OF_SCOPE
App key after revocation403APPKEY_REVOKED
App key trying to change a rule (share / owner / enforced)403FORBIDDEN
App key trying to mint or list keys401KEY_REQUIRED
App key on a route its account lost access to403FORBIDDEN
App key on POST /v2/keys/rotate400APPKEY_USE_REVOKE
Non-owner changing a rule403FORBIDDEN
Claiming a reserved platform route403RESERVED_SYSTEM_ROUTE
Route with enforced:false, even for the owner403FORBIDDEN

Two habits follow from that table. Do not read a 401 as "bad credential" without checking whether the route is new and out of scope, or whether an app key is being used on an account-only door. Do not read a 200 as "it worked" on any of the mutation doors — read changed.

Error reference

Every failure is a JSON object with ok:false, a human error and a stable code. Match on code. Every code below was observed live except where the row says otherwise.

CodeStatusMeans
KEY_REQUIRED401No key on the request, or a key kind this door does not accept.
UNKNOWN_KEY401The key resolved to no account. Also what an app key gets claiming a route outside its scope, and on GET /v2/routes.
NOT_PRINCIPAL403Authenticated, but not owner and holding no share that permits this op.
FORBIDDEN403Not the owner, on a door that only the owner may use. Also an unenforced route, and an app key on a route its account lost.
APPKEY_OUT_OF_SCOPE403App key, route or op outside what it was minted for.
APPKEY_REVOKED403App key was revoked. Mint a new one; this one never comes back.
APPKEY_USE_REVOKE400An app key was sent to POST /v2/keys/rotate. App keys are ended by revoking, not rotating.
RESERVED_SYSTEM_ROUTE403Route prefix belongs to the platform.
NO_RULE404No rule on that route — nobody has claimed it, or it is not enforced.
HANDLE_NOT_FOUND404The @handle in a share path resolves to no account. Nothing applied.
INVALID_PUBLIC_ID400Principal or new_owner_pid is not 12 lowercase hex (or a handle, or @view).
INVALID_PERM400perm was neither "read" nor "read-write".
MISSING_FIELD400A required field is absent, or present with the wrong JSON type.
UNKNOWN_FIELD400A field the door does not recognise. Nothing applied.
INVALID_JSON400Body did not parse, or was not a JSON object.
UNKNOWN_PARAM400A query parameter was sent. None of these endpoints take any.
BAD_PATH400The path does not name a valid resource — e.g. an app key id whose length is not 12.
PUBLISH_UNAVAILABLE503Not observed. The @view share was not applied because the publishing cloud was unreachable.
PUBLISH_FAILED502Not observed. The @view copy-out failed; the share was not applied.
ACL_DEGRADED503Not observed. The rule could not be read, so the request was denied rather than guessed. Retry.
TRANSIENT503Not observed. Identity lookup did not complete. Your key was not proven unknown. Retry.

The 400s share one guarantee, stated in the message itself: nothing was applied. A rejected mutation never partially lands.

Treat the 503s as retryable and never as an answer about access. Both mean the system declined to decide, and it fails closed rather than guessing. Neither could be induced from outside, so handle them by status code and retry rather than by matching the message text.

One more you may see that is not in the table: a bare error code: 502 from the edge, with no JSON body, on a revoke. It happened to five of nine consecutive DELETE /v2/appkeys/{id} calls here; all five succeeded on retry. Retry idempotently and confirm with changed.

A short checklist before you hand a key to anyone

  1. Call GET /v2/whoami with the new key. Read app_key.routes, can_read and can_write back. Do not trust the body you sent.
  2. If you meant read-only, confirm you passed can_write:false. permissions does nothing.
  3. Try one request the key should not be able to make, and confirm the 403. A scope you have not seen refuse anything is a scope you have not tested.
  4. If you used an exact route pattern, remember it excludes children. Use route/* for a subtree, and remember that a trailing /* also includes the parent.
  5. Record the id at mint time. Revoke by id takes about 4 seconds; revoke by name takes over a minute, and GET /v2/appkeys takes about as long — so if you did not keep the id, recovering it is itself the slow step.
Base URL https://api2.infiniteocean.io · authenticate with the X-Ocean-Key header.