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.
| Endpoint | Writes | Use it when |
|---|---|---|
POST /v2/drop | one record | a single value changed |
POST /v2/drops | many records, one route, all-or-nothing | you 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.
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.
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.
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.
/, and no empty segments. a//b, /a
and a/ all return 400 INVALID_INPUT._docs_probe/x0824/a/b/c works. A single segment
with no slash at all also works: _docs_probe_flat0824._docs_probe/no tes was accepted at 200._docs_probe/ prefix: 1018 bytes returns 200, 1019 bytes and everything above it
returns 503 ACL_DEGRADED on every attempt.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.
400 INVALID_INPUT. An empty key is 400 INVALID_INPUT.400 INVALID_INPUT.Ísland-Ø-日本 stores and reads back correctly, percent-encoded in
the read URL./ in a key is accepted, but see the warning below.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.
payload can be any JSON value — object, array, string, number, boolean, or
null. All of these were written and read back unchanged:
Sent as payload | Read 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}]}}} |
42 | 42 |
true | true |
null | null |
"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.
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 reservedA 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.
POST /v2/drop returns:
| Field | Meaning |
|---|---|
ok | Always true on a 200. Errors return false with a code. |
seq | The storage batch this write landed in. Pass it to GET /v2/receipt/{seq} to check durability. |
drop_id | The same number as seq, as a string. There is no separate id namespace — you address the record by route and key. |
ts | The timestamp the store recorded, epoch milliseconds. This is the value that decides last-write-wins. |
state | "accepted" — see below. |
rule_created | true if this write claimed a previously unclaimed route. |
route, key | Echoed back, so a client that batches calls can match responses to requests. |
offset, len | Where 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. |
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}
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.
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 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"}
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.
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.
400 BAD_TS.ts: 1000000000000 came
back with that same value in the response.ts: 1787597129758 (one hour ahead) came back as
"ts":1787593531050 — the server's current time. You get a 200 and a different
timestamp than you asked for, so read ts in the response rather than assuming your
value was kept.POST /v2/dropsOne 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:
| Field | Meaning |
|---|---|
count | How many records were written. Compare it against the length of the array you sent. |
seq, seq_last | First and last storage batch. Poll either with GET /v2/receipt/{seq}. |
multi_batch | Marks a set that spanned more than one batch. Never seen true in these runs — see the caveats. |
state, rule_created, route | As 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}
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"}
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.
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:
| Run | 10 sequential /v2/drop | 1 × /v2/drops of 10 |
|---|---|---|
| 1 | 16.1 s | 317 ms |
| 2 | 9.8 s | 318 ms |
| 3 | 7.4 s | 345 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.
Every error returns {"ok":false,"error":"...","code":"..."}. Branch on code,
not on the message text. Every 4xx listed here stores nothing.
| Status | code | Cause |
|---|---|---|
| 401 | KEY_REQUIRED | No X-Ocean-Key header. |
| 401 | UNKNOWN_KEY | The key is not recognised. |
| 400 | UNKNOWN_FIELD | A top-level body field other than route, key, payload, ts — or, in a batch record, other than key, payload, ts. |
| 400 | MISSING_FIELD | route, key or payload absent, or route/key not strings. On /v2/drops: route or records absent. |
| 400 | INVALID_JSON | The request body is not valid JSON, or is valid JSON that is not an object (an array body gives this too). |
| 400 | INVALID_INPUT | Route 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. |
| 503 | ACL_DEGRADED | The 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. |
| 400 | BAD_TS | ts is present but not an integer. |
| 400 | RESERVED_FIELD | The payload object has a top-level agent field. In a batch the message names the record index. |
| 400 | EMPTY_RECORDS | /v2/drops with "records":[]. |
| 400 | BAD_RECORD | A batch record is not an object, or is missing key/payload. The message names the index. |
| 400 | UNKNOWN_PARAM | Any query parameter on either write door. The message names the parameter. |
| 404 | NOT_FOUND | Wrong method or path — for example GET /v2/drop, or POST /v2/drops/extra. |
| 413 | PAYLOAD_TOO_LARGE | Request 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.
| Limit | Value |
|---|---|
| Request body | 4 MiB (4,194,304 bytes), enforced with a 413 that names the number |
| Route length | 1018 bytes accepted, 1019 refused with 503 ACL_DEGRADED. Bisected under the _docs_probe/ prefix. |
| Key length | 2048 bytes. Boundary confirmed: 2048 accepted, 2049 returns 400 INVALID_INPUT. |
| Records per batch | No cap found; 20,000 records / 2.3 MB succeeded in one request. The 4 MiB body limit is the practical bound. |
| Payload size | Bounded 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. |
https://api2.infiniteocean.io · authenticate with the X-Ocean-Key header.