InfiniteOceanv2

Writing data

POST /v2/drop and POST /v2/drops — route and key semantics, route claiming, payload shapes, last-write-wins by timestamp, and what "accepted" means.

Writing data to InfiniteOcean means posting a drop: a JSON value stored under a route and a key. There are two doors — one drop at a time, or many at once.

Every example on this page was run against https://api2.infiniteocean.io and the responses are the real ones, pasted unedited. The routes in the examples all begin with _docs_probe/, which is the namespace these probes were run in and is already claimed by the account that ran them. Substitute a route of your own when you copy an example.

The two doors

EndpointWritesUse it when
POST /v2/dropone recorda single value changed
POST /v2/dropsmany records, one route, all-or-nothingyou have more than a couple of records

Both require an X-Ocean-Key header. Neither accepts any query parameter — the server returns 400 UNKNOWN_PARAM for anything you append to the URL. Both paths are matched exactly: POST /v2/drops/extra is 404 NOT_FOUND.

Your first write

curl -X POST https://api2.infiniteocean.io/v2/drop \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"route":"_docs_probe/orders","key":"order-1001","payload":{"customer":"acme","total":49.95,"status":"pending"}}'
{"ok":true,"seq":1787577342969360,"offset":0,"len":126,"ts":1787593454346,
 "state":"accepted","rule_created":false,"drop_id":"1787577342969360",
 "route":"_docs_probe/orders","key":"order-1001"}

That is the whole write path. Three required fields — route, key, payload — and one optional one, ts. Any other top-level field is rejected with 400 UNKNOWN_FIELD and nothing is stored.

Routes and keys

A route is a folder path: _docs_probe/orders, myapp/users, logs/2026/august. A key names one record inside it. Together they are the address of a value, and they are the only way to address it — there is no separate document id you have to keep.

The first write to a route claims it

Routes are not created ahead of time. The first successful write to an unclaimed route returns rule_created: true; every later write to the same route returns false. The write above returned false because that route had already been claimed by an earlier run — here is the same call against a route that did not yet exist:

curl -X POST https://api2.infiniteocean.io/v2/drop \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"route":"_docs_probe/v2claim0824a","key":"first","payload":{"hello":"world"}}'
{"ok":true,"seq":1787577342969367,"offset":0,"len":92,"ts":1787593459138,
 "state":"accepted","rule_created":true,"drop_id":"1787577342969367",
 "route":"_docs_probe/v2claim0824a","key":"first"}

There is no separate "create route" call. Claiming is a side effect of writing, and it leaves an access rule behind that you can read on the access door:

curl https://api2.infiniteocean.io/v2/access/_docs_probe/v2claim0824a -H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"route":"_docs_probe/v2claim0824a","owner":"<your-key-id>",
 "owner_handle":"<your-handle>","enforced":true,"shares":[]}

(owner and owner_handle are the only two values replaced anywhere on this page; everything else is verbatim.) Who may write to a claimed route is the access-control page's subject. The only thing the write doors themselves tell you is that a request carrying no X-Ocean-Key is 401 KEY_REQUIRED and one carrying an unrecognised key is 401 UNKNOWN_KEY.

What makes a valid route

A bad route name returns 503 first, then 400

This is worth knowing before you write retry logic. The access rule for the route is read before the route name is validated, and that read cannot handle these names, so the first request for a given bad route name comes back 503 ACL_DEGRADED. Send the identical request again and you get 400 INVALID_INPUT. Either way nothing is written.

# attempt 1, on a route name never seen before
curl -X POST https://api2.infiniteocean.io/v2/drop \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"route":"_docs_probe/demo123456\"x","key":"k","payload":{}}'
{"ok":false,"error":"access rule unreadable — denied","code":"ACL_DEGRADED"}   # 503

# attempt 2, byte-identical request
{"ok":false,"error":"invalid input — nothing was applied","code":"INVALID_INPUT"}   # 400

Measured on six freshly generated route names, five attempts each. Quote, backslash, newline, carriage return and byte 0x01 all went 503, 400, 400, 400, 400. Three route shapes never flip and return 503 on every attempt:

ACL_DEGRADED caused by a route name is permanent in the sense that matters: the write never lands, however many times you retry. If your client retries on 503, branch on code and exclude this one, or you will spin against a request that cannot succeed.

The same characters in a key behave as you would expect and return 400 INVALID_INPUT on the first attempt, because keys are validated at write time, once the route is already sound.

What makes a valid key

A slash in a key is a trap on the read side. The write succeeds, but the read path /v2/entity/{route}/{key} splits the path at the last slash, so an un-encoded key with a slash resolves to the wrong route. Writing key a/b to _docs_probe/notes returns 200, and then:

# un-encoded — the path splits at the last slash, so this asks for
# key "b" on route "_docs_probe/notes/a", which is not your record
curl "https://api2.infiniteocean.io/v2/entity/_docs_probe/notes/a/b" -H "X-Ocean-Key: $OCEAN_KEY"
{"ok":false,"error":"forbidden","code":"FORBIDDEN"}      # 403

# percent-encoded — correct
curl "https://api2.infiniteocean.io/v2/entity/_docs_probe/notes/a%2Fb" -H "X-Ocean-Key: $OCEAN_KEY"
{"kc":"slash"}                                            # 200

Either avoid slashes in keys, or percent-encode them as %2F on every read. The same applies to non-ASCII keys, which must be percent-encoded in the URL.

Payloads

payload can be any JSON value — object, array, string, number, boolean, or null. All of these were written and read back unchanged:

Sent as payloadRead back
{"customer":"acme","total":49.95}{"customer":"acme","total":49.95}
{}{}
[1,2,3][1,2,3]
{"a":{"b":{"c":[1,{"d":null}]}}}{"a":{"b":{"c":[1,{"d":null}]}}}
4242
truetrue
nullnull
"a plain string, not an object""a plain string, not an object"

A stored null reads back as null with status 200. A missing key returns 404 NOT_FOUND, so the two are distinguishable. Omitting payload altogether is 400 MISSING_FIELD — it is not the same as sending null.

Newlines inside payload strings survive. {"text":"line1\nline2\r\nline3"} wrote at 200 and read back identically.

A string that contains JSON is unwrapped

This is the one payload behaviour that will surprise you. If you send a string whose contents parse as JSON, the server stores the parsed JSON, not the string:

curl -X POST https://api2.infiniteocean.io/v2/drop \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"route":"_docs_probe/notes","key":"stringified","payload":"{\"a\":1,\"b\":\"two\"}"}'
# the write reports 200 ...
{"ok":true,"seq":1787577342969393,"offset":0,"len":91,"ts":1787593506226,
 "state":"accepted","rule_created":false,"drop_id":"1787577342969393",
 "route":"_docs_probe/notes","key":"stringified"}

# ... and the value is an OBJECT, not the string you sent
curl https://api2.infiniteocean.io/v2/entity/_docs_probe/notes/stringified -H "X-Ocean-Key: $OCEAN_KEY"
{"a":1,"b":"two"}

A string that is not valid JSON stays a string: "a plain string, not an object" read back exactly as sent.

The consequence: you cannot store the literal text {"a":1} as a string. Send objects as objects — do not pre-serialise your payload with JSON.stringify before putting it in the body.

agent is reserved

A top-level agent field in an object payload is refused. The server sets that field itself for writes made with an agent key:

{"ok":false,"error":"payload must not contain a top-level `agent` field — the server sets it","code":"RESERVED_FIELD"}

Only the top level is reserved — {"meta":{"agent":"me"}} wrote at 200. In a batch the error names the record index: record 0: payload must not contain a top-level `agent` field.

The response

POST /v2/drop returns:

FieldMeaning
okAlways true on a 200. Errors return false with a code.
seqThe storage batch this write landed in. Pass it to GET /v2/receipt/{seq} to check durability.
drop_idThe same number as seq, as a string. There is no separate id namespace — you address the record by route and key.
tsThe timestamp the store recorded, epoch milliseconds. This is the value that decides last-write-wins.
state"accepted" — see below.
rule_createdtrue if this write claimed a previously unclaimed route.
route, keyEchoed back, so a client that batches calls can match responses to requests.
offset, lenWhere the record sits inside its storage batch, in bytes. Internal coordinates; you do not need them, and you should not assume offset is 0. It was 0 in almost every write made for this page, and 429 in one of them.

What "accepted" means

state: "accepted" means the write is on the answering node's local disk. It does not yet mean the write is in cloud object storage — that is durable, and it follows shortly after.

The write door returns as soon as the write is accepted rather than waiting for the cloud. To confirm the stronger state, poll the receipt with the seq you were given:

curl https://api2.infiniteocean.io/v2/receipt/1787577342969372 -H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"seq":1787577342969372,"state":"durable","node":"hil"}

In the runs made for this page every receipt polled immediately after the write already read durable. A seq the answering node knows nothing about returns 404 NOT_FOUND. Note that the receipt response names the node that answered ("node":"hil") — receipts are that node's knowledge, so a 404 is that node's answer, not the fleet's.

Your own data is readable immediately after the 200 regardless — a read issued straight after the write, with no delay, returned the new value:

curl https://api2.infiniteocean.io/v2/entity/_docs_probe/orders/order-1003 -H "X-Ocean-Key: $OCEAN_KEY"
{"customer":"initech","total":7.5}

Deleting a drop

DELETE /v2/entity/{route}/{key} removes one key. It needs the same permission as writing: anyone who can overwrite the key can already destroy its value, so deleting grants nothing extra.

curl -X DELETE https://api2.infiniteocean.io/v2/entity/_docs_probe/notes/draft-1 \
  -H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"deleted":true,"seq":1787747429630237,"ts":1787749874976}

curl https://api2.infiniteocean.io/v2/entity/_docs_probe/notes/draft-1 -H "X-Ocean-Key: $OCEAN_KEY"
{"ok":false,"error":"entity not found","code":"NOT_FOUND"}

The key stops appearing in /v2/list too, and — this is the part that matters for paging — a deleted key does not consume a slot in your limit. Ask for 20 rows on a route with 5 deleted keys and you get 20 live rows, not 15.

A delete is a write, not an erasure

Deleting appends a record like any other write, and it obeys the same last-write-wins rule. So writing the key again after deleting it brings it back — the newer write beats the delete:

curl -X DELETE https://api2.infiniteocean.io/v2/entity/_docs_probe/notes/draft-1 -H "X-Ocean-Key: $OCEAN_KEY"
curl -X POST https://api2.infiniteocean.io/v2/drop -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"route":"_docs_probe/notes","key":"draft-1","payload":{"title":"Back again"}}'

curl https://api2.infiniteocean.io/v2/entity/_docs_probe/notes/draft-1 -H "X-Ocean-Key: $OCEAN_KEY"
{"title":"Back again"}

It also means the earlier versions are still in your history. Delete is not erasure. If you need the bytes actually destroyed — a GDPR right-to-erasure request, say — use /v2/gdpr/erase, which rewrites the underlying storage. Delete only changes what the read doors answer.

Deleting something that is not there

Deleting a key that never existed returns 200, not 404. The call is idempotent, so a retry after a network timeout is safe and you never have to check first:

curl -X DELETE https://api2.infiniteocean.io/v2/entity/_docs_probe/notes/never-existed \
  -H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"deleted":true,"seq":1787747429630239,"ts":1787749876309}

Worth knowing: this does write a record. Deleting keys in a loop "just in case" leaves one marker per key, so prefer deleting keys you know are there.

Without a key you get 401:

curl -X DELETE https://api2.infiniteocean.io/v2/entity/_docs_probe/notes/draft-1
{"ok":false,"error":"X-Ocean-Key required","code":"KEY_REQUIRED"}

Last write wins — by timestamp, not by arrival

Writing the same key twice replaces the value that the read doors return:

curl -X POST https://api2.infiniteocean.io/v2/drop \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"route":"_docs_probe/orders","key":"order-1001","payload":{"customer":"acme","total":49.95,"status":"shipped"}}'

curl https://api2.infiniteocean.io/v2/entity/_docs_probe/orders/order-1001 -H "X-Ocean-Key: $OCEAN_KEY"
{"customer":"acme","total":49.95,"status":"shipped"}

The winner is the record with the highest ts — not the one that arrived last. Normally these are the same thing, because the server stamps ts from its own clock. But if you supply ts yourself, a backdated write loses even though it arrived second:

# write v1 with the server clock
-d '{"route":"_docs_probe/notes","key":"ts-test-0824","payload":{"v":1}}'
→ "ts":1787593525562

# write v2 SECOND, but backdated to 2001
-d '{"route":"_docs_probe/notes","key":"ts-test-0824","payload":{"v":2},"ts":1000000000000}'
→ "ts":1000000000000        (200 OK)

# read back — v1 still wins
curl https://api2.infiniteocean.io/v2/entity/_docs_probe/notes/ts-test-0824 -H "X-Ocean-Key: $OCEAN_KEY"
{"v":1}

The backdated write was accepted, stored, and lost the comparison. That is the intended behaviour for backfill, but it means a client with a wrong clock can write records that silently never surface.

When two separate writes share a ts, the first one written wins. Two writes with the same explicit ts of 1750000000000 left {"which":"A"} readable on both the entity and the list door, not B. Do not rely on sub-millisecond ordering.

Writing with your own timestamp

The optional ts field takes epoch milliseconds as an integer. It exists for migration and backfill, where the record's real time is not the time you happen to be posting it.

Batch writes: POST /v2/drops

One route, many records, one request. The body is {"route": ..., "records": [...]} where each record is {"key": ..., "payload": ..., "ts": ...} — ts optional, exactly as on the single door.

curl -X POST https://api2.infiniteocean.io/v2/drops \
  -H "Content-Type: application/json" \
  -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"route":"_docs_probe/v2evt0824a","records":[
    {"key":"evt-1","payload":{"kind":"signup","user":"alice"}},
    {"key":"evt-2","payload":{"kind":"login","user":"alice"}},
    {"key":"evt-3","payload":{"kind":"purchase","user":"bob","amount":30}}
  ]}'
{"ok":true,"seq":1787577342969382,"seq_last":1787577342969382,"count":3,
 "state":"accepted","rule_created":true,"multi_batch":false,
 "route":"_docs_probe/v2evt0824a"}

The response is per-set, not per-record:

FieldMeaning
countHow many records were written. Compare it against the length of the array you sent.
seq, seq_lastFirst and last storage batch. Poll either with GET /v2/receipt/{seq}.
multi_batchMarks a set that spanned more than one batch. Never seen true in these runs — see the caveats.
state, rule_created, routeAs on the single door. The batch door claims an unclaimed route the same way.

All records in one batch receive the same ts unless you set ts per record. Listing the three events above:

curl https://api2.infiniteocean.io/v2/list/_docs_probe/v2evt0824a -H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"items":[
  {"key":"evt-1","ts":1787593495258,"value":{"kind":"signup","user":"alice"}},
  {"key":"evt-2","ts":1787593495258,"value":{"kind":"login","user":"alice"}},
  {"key":"evt-3","ts":1787593495258,"value":{"kind":"purchase","user":"bob","amount":30}}],
 "returned":3,"complete":false,"index":false,"next_cursor":null}

It is all-or-nothing

Every record is validated before any is written. If record 5 is bad, records 1–4 do not land. Proven directly — a two-record batch with a valid first record and an empty key second:

curl -X POST https://api2.infiniteocean.io/v2/drops \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"route":"_docs_probe/events","records":[
    {"key":"evt-atomic-0824","payload":{"should":"not land"}},
    {"key":"","payload":{}}]}'

{"ok":false,"error":"invalid input — nothing was applied","code":"INVALID_INPUT"}   # 400

# the valid record did not leak in
curl https://api2.infiniteocean.io/v2/entity/_docs_probe/events/evt-atomic-0824 -H "X-Ocean-Key: $OCEAN_KEY"
{"ok":false,"error":"entity not found","code":"NOT_FOUND"}                          # 404

Errors that name a record give you its zero-based index, so you can fix the input without bisecting:

{"ok":false,"error":"record 1 must be an object with key and payload — nothing was applied","code":"BAD_RECORD"}

Do not repeat a key inside one batch

This is a real defect, not a caution. Two records with the same key inside one batch are both written, and the read endpoints then disagree about which one is current. The result is a value that depends on which endpoint you ask.

-d '{"route":"_docs_probe/dup0824","records":[
      {"key":"dup","payload":{"pos":"first"}},
      {"key":"dup","payload":{"pos":"second"}}]}'
{"ok":true,"seq":1787577342969405,"seq_last":1787577342969405,"count":2,...}   # 200

curl .../v2/entity/_docs_probe/dup0824/dup   →  {"pos":"first"}
curl .../v2/list/_docs_probe/dup0824         →  {"key":"dup","ts":1787593578762,"value":{"pos":"second"}}

Reproduced with a three-record duplicate in the same run (entity returned {"pos":"A"}, list returned {"pos":"C"}), and the disagreement is stable rather than a propagation delay. De-duplicate by key on the client before sending. If you genuinely need to record several values for the same key, send them as separate requests so they get distinct timestamps, or give each one an explicit distinct ts.

Why batching is worth it

A single write costs on the order of a second, and that cost is paid per request. Ten records written one at a time versus the same ten in one batch, measured three times, alternating:

Run10 sequential /v2/drop1 × /v2/drops of 10
116.1 s317 ms
29.8 s318 ms
37.4 s345 ms

The absolute numbers vary a lot run to run, but the direction did not: batching ten records was 21–51× faster in every round. Large sets are fine in one request — a 20,000-record batch of 2.3 MB returned count: 20000 in 4.4 seconds. If you are writing a loop over POST /v2/drop, use POST /v2/drops instead.

Errors

Every error returns {"ok":false,"error":"...","code":"..."}. Branch on code, not on the message text. Every 4xx listed here stores nothing.

StatuscodeCause
401KEY_REQUIREDNo X-Ocean-Key header.
401UNKNOWN_KEYThe key is not recognised.
400UNKNOWN_FIELDA top-level body field other than route, key, payload, ts — or, in a batch record, other than key, payload, ts.
400MISSING_FIELDroute, key or payload absent, or route/key not strings. On /v2/drops: route or records absent.
400INVALID_JSONThe request body is not valid JSON, or is valid JSON that is not an object (an array body gives this too).
400INVALID_INPUTRoute shape is wrong (leading/trailing slash, empty path segment); or the key is empty, over 2048 bytes, or contains a quote, backslash, tab or newline; or a repeat attempt at a route name that first returned ACL_DEGRADED.
503ACL_DEGRADEDThe route name is unusable — empty, over 1018 bytes, or containing a quote, backslash, tab, newline, carriage return or other control byte. The first attempt at any such name gives this; most give 400 INVALID_INPUT on the second. Despite the 5xx it is never worth retrying: fix the route.
400BAD_TSts is present but not an integer.
400RESERVED_FIELDThe payload object has a top-level agent field. In a batch the message names the record index.
400EMPTY_RECORDS/v2/drops with "records":[].
400BAD_RECORDA batch record is not an object, or is missing key/payload. The message names the index.
400UNKNOWN_PARAMAny query parameter on either write door. The message names the parameter.
404NOT_FOUNDWrong method or path — for example GET /v2/drop, or POST /v2/drops/extra.
413PAYLOAD_TOO_LARGERequest body over 4 MiB. The response names the limit.

Two you should handle explicitly:

# unknown query parameter — names the offender
{"ok":false,"error":"unknown query parameter \"dedupe\" — nothing was applied","code":"UNKNOWN_PARAM"}

# oversized body — names the byte limit so you can pre-filter
{"ok":false,"error":"body of 4500068 bytes exceeds the 4194304-byte limit — nothing was applied",
 "code":"PAYLOAD_TOO_LARGE","limit_bytes":4194304}

Do not retry blindly on any 503. A node under pressure can also return 503, and those are retryable, but ACL_DEGRADED from a bad route name is not. Branch on code. A write is addressed by route and key, so re-sending an identical request after a genuine transient failure overwrites with the same value rather than creating a duplicate.

Limits

LimitValue
Request body4 MiB (4,194,304 bytes), enforced with a 413 that names the number
Route length1018 bytes accepted, 1019 refused with 503 ACL_DEGRADED. Bisected under the _docs_probe/ prefix.
Key length2048 bytes. Boundary confirmed: 2048 accepted, 2049 returns 400 INVALID_INPUT.
Records per batchNo cap found; 20,000 records / 2.3 MB succeeded in one request. The 4 MiB body limit is the practical bound.
Payload sizeBounded by the body limit. A 3 MB payload (3,000,073-byte body) wrote at 200 and read back all 3,000,000 characters intact.
Base URL https://api2.infiniteocean.io · authenticate with the X-Ocean-Key header.