Turn a route into a public website on view2.infiniteocean.io by sharing it with @view, and register custom domains with /v2/domains.
A route becomes a public website when you share it with the principal @view. Every entity in that route that carries a _body field becomes one page, served without a key at https://view2.infiniteocean.io/{route}/{key}.
There is no /v2/publish endpoint — POST /v2/publish returns 404 NOT_FOUND. Publishing is an ACL operation: PUT /v2/access/{route}/shares/@view. Unpublishing is DELETE on the same path.
Three calls: write a page, publish the route, fetch it. Pick a route name of your own — the first account to write to a route claims it, and a later write by a different account is refused. The responses below are the real output of this sequence on the route _docs_probe/qs1787594494; substitute your own name everywhere it appears.
# 1. Write the page. The HTML goes in payload._body
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/qs1787594494","key":"index",
"payload":{"_body":"<!doctype html><title>Hello</title><h1>Hello, world</h1>"}}'
# 2. Publish the route (the route is percent-encoded; so is the @ in @view)
curl -s -X PUT "https://api2.infiniteocean.io/v2/access/_docs_probe%2Fqs1787594494/shares/%40view" \
-H "Content-Type: application/json" \
-H "X-Ocean-Key: $OCEAN_KEY" \
-d '{"perm":"read"}'
# 3. Fetch it — no key
curl -s https://view2.infiniteocean.io/_docs_probe/qs1787594494/
Real responses from that exact sequence:
{"ok":true,"seq":1787577342970143,"offset":0,"len":143,"ts":1787594499534,
"state":"accepted","rule_created":true,"drop_id":"1787577342970143",
"route":"_docs_probe/qs1787594494","key":"index"}
{"ok":true,"route":"_docs_probe/qs1787594494","pid":"@view","perm":"read","changed":true}
<!doctype html><title>Hello</title><h1>Hello, world</h1>
"rule_created":true in the first response means that write claimed the route for your account. Writing to the same route again returns "rule_created":false.
One entity is one page. When you share a route with @view, the server copies every current entity in that route into a public object store, keyed by the URL path a browser would ask for. After that, every write to the route updates its published copy as part of the write — you do not re-publish.
Consequences worth knowing up front:
_body field is world-readable.api2.infiniteocean.io) still requires a key for the same data. Publishing does not change who can call /v2/entity/ or /v2/list/ — both return 401 KEY_REQUIRED without one, published or not.Base host is https://view2.infiniteocean.io. The path under it is your route, then the entity key.
| Route | Entity key | URL |
|---|---|---|
_docs_probe/sitedemo | index | /_docs_probe/sitedemo/ |
_docs_probe/sitedemo | about | /_docs_probe/sitedemo/about |
_docs_probe/sitedemo | style.css | /_docs_probe/sitedemo/style.css |
The key index is the route's front page. It is served at the directory URL and also at /index.html.
Measured, not inferred:
curl -o /dev/null -w '%{http_code}\n' https://view2.infiniteocean.io/_docs_probe/sitedemo/
200
curl -o /dev/null -w '%{http_code}\n' https://view2.infiniteocean.io/_docs_probe/sitedemo
404
A route with a / in it serves its index only at the URL with the trailing slash. Without the slash you get a miss. The same result was measured on a second multi-segment route (_docs_probe/hello): 200 on three tries with the slash, 404 on three tries without it.
A single-segment route (docsprobesingle) serves at /docsprobesingle, /docsprobesingle/ and /docsprobesingle/index.html — all three returned 200.
Always link to the trailing-slash form, and use relative links inside your HTML so they resolve under the directory.
Pages are normal drops. The only special field is _body, whose string value becomes the exact bytes served.
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/sitedemo","key":"about",
"payload":{"_body":"<!doctype html><h1>About</h1><p>Second page.</p>"}}'
{"ok":true,"seq":1787577342970140,"offset":0,"len":136,"ts":1787594492258,
"state":"durable","rule_created":false,"drop_id":"1787577342970140",
"route":"_docs_probe/hello","key":"index"}
state is "accepted" or "durable" depending on how far the write has got when the response is written. Both mean the write succeeded; neither changes what gets published.
A 32 KB HTML body was written and served back byte-for-byte (32027 bytes downloaded). Nothing larger was tested — see the limits note at the end.
The served Content-Type is derived from the file extension in the URL, not from anything in the payload. Name your keys the way you want them served.
| Entity key | URL requested | Content-Type returned |
|---|---|---|
index | /_docs_probe/sitedemo/ | text/html; charset=utf-8 |
about | /_docs_probe/sitedemo/about | application/octet-stream |
about | /_docs_probe/sitedemo/about.html | text/html; charset=utf-8 |
style.css | /_docs_probe/sitedemo/style.css | text/css; charset=utf-8 |
feed.json | /_docs_probe/sitedemo/feed.json | application/json |
An extensionless key other than index is served as application/octet-stream, and every response carries x-content-type-options: nosniff, so a browser will download it rather than render it. Appending .html to the URL serves the same bytes as text/html — measured for about, feed and even style.css (/style.css.html returned 200 text/html). The reliable fix is to name the key about.html and link to that.
_content_type in the payload has no effect on what is served. A key named feed written with {"_body":"...","_content_type":"application/json"} was served as application/octet-stream. The same bytes written under the key feed.json were served as application/json. Use the key name.
Write the same route and key again. The published copy is updated as part of the write — there is no second call and no cache purge step. In testing the new bytes were already live on the first fetch after the write returned, and on every fetch for the next six seconds.
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/qs1787594494","key":"index",
"payload":{"_body":"<!doctype html><h1>Updated</h1>"}}'
curl -s https://view2.infiniteocean.io/_docs_probe/qs1787594494/
<!doctype html><h1>Updated</h1>
That was measured on an HTML page, whose responses carry cf-cache-status: DYNAMIC. A file with a static extension such as .css can be served from the Cloudflare edge cache (cf-cache-status: HIT), so an update to one of those may not be visible immediately. See the unpublish section.
Two kinds of entity in a published route stay private. Both were written into a freshly published route and then fetched five times each on the public host; every fetch was a miss (504 or 404 — see "Behaviour of the public host" below):
_body field. A drop written as {"title":"not web content","n":1} never serves. Ordinary data drops in a published route are not exposed as pages._. A key _hidden with a valid _body is skipped.Both are still readable through the API with your key:
curl -s "https://api2.infiniteocean.io/v2/entity/_docs_probe%2Fqs1787594494/_hidden" \
-H "X-Ocean-Key: $OCEAN_KEY"
{"_body":"<p>secret</p>"}
GET /v2/access/{route} returns the route's rule. A @view entry in shares means published.
curl -s "https://api2.infiniteocean.io/v2/access/_docs_probe%2Fsitedemo" \
-H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"route":"_docs_probe/sitedemo","owner":"9a92e0aed98f",
"owner_handle":"ftft1787581023h","enforced":true,
"shares":[{"pid":"@view","perm":"read","expires_at_ms":0,"expired":false}]}
An unpublished route returns the same shape with an empty shares array.
There is no endpoint that lists only your published routes. Use GET /v2/routes for your routes, then check each rule.
GET /v2/access/{route} takes no query parameters. An unrecognised one is a hard 400, not an ignored extra:
curl -s "https://api2.infiniteocean.io/v2/access/_docs_probe%2Fsitedemo?expand=1" \
-H "X-Ocean-Key: $OCEAN_KEY"
{"ok":false,"error":"unknown query parameter \"expand\" — nothing was applied","code":"UNKNOWN_PARAM"}
curl -s -X DELETE \
"https://api2.infiniteocean.io/v2/access/_docs_probe%2Frwtest2/shares/%40view" \
-H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"route":"_docs_probe/rwtest2","share_removed":true,"changed":true}
The route stops serving straight away: / and /a.html both became misses (504) within two seconds. Calling DELETE again is safe and returns "changed":false. Calling it on a route that has no access rule at all returns 404 NO_RULE.
Cloudflare's edge cache is not purged. In the same test, /_docs_probe/rwtest2/s.css still returned 200 after unpublishing, with cf-cache-status: HIT. HTML responses carry cf-cache-status: DYNAMIC and stop immediately; static extensions such as .css, .js and images can keep serving from the edge until the cache entry ages out. Do not rely on unpublish to make a cached asset unreachable to someone who already has the URL.
You cannot share a route nothing has been written to. The first POST /v2/drop to a route creates its access rule (the response shows "rule_created":true).
curl -s -X PUT \
"https://api2.infiniteocean.io/v2/access/_docs_probe%2Fnever-claimed-xyz2/shares/%40view" \
-H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
-d '{"perm":"read"}'
404
{"ok":false,"error":"no access rule on this route — nothing was applied","code":"NO_RULE"}
@view accepts only "perm":"read". Sending "read-write" returns 400, and the error text says nothing was applied — but the published copies are deleted anyway, while the rule keeps showing @view. The site stops serving and the ACL says it is published.
curl -s -X PUT \
"https://api2.infiniteocean.io/v2/access/_docs_probe%2Frwtest2/shares/%40view" \
-H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
-d '{"perm":"read-write"}'
400
{"ok":false,"error":"invalid rule mutation — nothing was applied","code":"INVALID_INPUT"}
Observed on a freshly published three-page route immediately after that call: / and /a.html both became misses (504 on 9 of 10 samples, 404 on the other), while GET /v2/access still listed the @view share with "perm":"read". Only /s.css kept returning 200, with cf-cache-status: HIT — an edge-cached copy, not evidence the object survived.
Recovery, and it is the same call that publishes: re-issue the share with "perm":"read". It re-copies every current entity. The response says "changed":false because the rule row did not move, but the pages come back.
curl -s -X PUT \
"https://api2.infiniteocean.io/v2/access/_docs_probe%2Frwtest2/shares/%40view" \
-H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
-d '{"perm":"read"}'
{"ok":true,"route":"_docs_probe/rwtest2","pid":"@view","perm":"read","changed":false}
All three URLs returned 200 again within three seconds. This makes a plain re-publish the general repair for a site whose pages are missing.
The share body accepts expires_at_ms, and the ACL honours it in its own reporting — but the public host keeps serving.
curl -s -X PUT \
"https://api2.infiniteocean.io/v2/access/_docs_probe%2Frwtest2/shares/%40view" \
-H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
-d '{"perm":"read","expires_at_ms":1787594892363}'
{"ok":true,"route":"_docs_probe/rwtest2","pid":"@view","perm":"read","changed":true}
Eight seconds after that timestamp passed, the rule reported the share as expired:
{"ok":true,"route":"_docs_probe/rwtest2",...,
"shares":[{"pid":"@view","perm":"read","expires_at_ms":1787594892363,"expired":true}]}
and the site still returned 200 — four fetches of / and three of /a.html, all 200. Do not use expires_at_ms to schedule an unpublish. The only thing that takes a site down is DELETE .../shares/@view.
The share body accepts perm and expires_at_ms. Any other field is rejected — note that the server's own error text says "only perm", which understates what it accepts. These fail before anything is touched, so a published site is not affected:
| Body | Status | Response |
|---|---|---|
{} | 400 | {"ok":false,"error":"body must include perm (read|read-write) — nothing was applied","code":"MISSING_FIELD"} |
{"perm":"read","public":true} | 400 | {"ok":false,"error":"unknown body field — body must include only perm (read|read-write); nothing was applied","code":"UNKNOWN_FIELD"} |
no X-Ocean-Key | 401 | {"ok":false,"error":"X-Ocean-Key required","code":"KEY_REQUIRED"} |
All four of these forms were accepted for the same route and principal, and all normalised to "pid":"@view":
/v2/access/_docs_probe%2Fhello/shares/%40view # both encoded
/v2/access/_docs_probe%2Fhello/shares/@view # literal @
/v2/access/_docs_probe/hello/shares/%40view # literal /
/v2/access/_docs_probe%2Fhello/shares/view # no @ at all
Use the fully encoded form.
HEAD returns 405 with an empty body. POST returns 404.view2.infiniteocean.io as an outage — for a missing page it is an ordinary result, and do not use this host as an existence check. Use GET /v2/entity/ with your key.{"ok":false,"error":"not found","code":"NOT_FOUND"}.view.infiniteocean.io is not the v2 host. The same published route returned 504 there with the body {"error":"origin unreachable and no R2 object for this path","code":"SITES_ORIGIN_TIMEOUT"}. Use view2.infiniteocean.io.access-control-allow-origin: *, access-control-expose-headers: Content-Range, Location and x-content-type-options: nosniff./v2/domains is a registry that binds a hostname you own to one of your routes. Registration is bookkeeping — it does not configure DNS or TLS for you, and see the limitation at the end of this section before you plan around it.
Lists domains registered by the presenting key. No parameters.
curl -s https://api2.infiniteocean.io/v2/domains -H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"domains":[
{"domain":"ft-ft1786397602.example.com","route":"ft1786397602/site"},
{"domain":"ft-ft1786401042.example.com","route":"ft1786401042/site"}
]}
The array holds one object per registration, with exactly the two fields shown. There is no status, verification or certificate field.
Required fields: domain and route, both strings. The convention is to bind example.com to the route sites/example.com.
curl -s -X POST https://api2.infiniteocean.io/v2/domains \
-H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
-d '{"domain":"docs-probe-1787594673.example.com",
"route":"sites/docs-probe-1787594673.example.com"}'
{"ok":true,"domain":"docs-probe-1787594673.example.com",
"route":"sites/docs-probe-1787594673.example.com"}
Re-posting the same pair with the same key returns the same 200 — registration is idempotent for the owner.
Validation errors:
| Request | Status | Response |
|---|---|---|
{"domain":"foo.infiniteocean.io","route":"sites/foo"} | 400 | {"ok":false,"error":"domain is not a valid hostname — nothing was applied","code":"INVALID_DOMAIN"} |
{"domain":"localhost","route":"sites/localhost"} | 400 | same INVALID_DOMAIN — at least two labels are required |
{"domain":"-bad.example.com",...} | 400 | same INVALID_DOMAIN — a label may not start or end with a hyphen |
{"domain":"x.example.com"} | 400 | {"ok":false,"error":"route required — nothing was applied","code":"MISSING_FIELD"} |
no X-Ocean-Key | 401 | {"ok":false,"error":"X-Ocean-Key required","code":"KEY_REQUIRED"} |
infiniteocean.io and any subdomain of it are refused.
The POST response echoes the casing you sent; the stored value is lowercased. Posting UPPER-1787594675.example.com returned "domain":"UPPER-1787594675.example.com", but GET /v2/domains then listed it as upper-1787594675.example.com. Compare domains case-insensitively; do not key your own records off the POST response verbatim. DELETE accepts either casing.
curl -s -X DELETE \
"https://api2.infiniteocean.io/v2/domains/docs-probe-1787594673.example.com" \
-H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"removed":true}
A domain not registered to your key — including one you already deleted — returns 404:
{"ok":false,"error":"domain not registered to this account","code":"NOT_FOUND"}
Registering the domain does not publish anything. The route still needs its @view share, exactly as above:
curl -s -X POST https://api2.infiniteocean.io/v2/drop \
-H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
-d '{"route":"sites/docs-probe.example.com","key":"index",
"payload":{"_body":"<h1>custom host root</h1>"}}'
curl -s -X PUT \
"https://api2.infiniteocean.io/v2/access/sites%2Fdocs-probe.example.com/shares/%40view" \
-H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
-d '{"perm":"read"}'
{"ok":true,"route":"sites/docs-probe.example.com","pid":"@view","perm":"read","changed":true}
Once published, the content is reachable on the shared host under the route path:
curl -s https://view2.infiniteocean.io/sites/docs-probe.example.com/
<h1>custom host root</h1>
Serving over a real customer hostname could not be verified from outside, so treat everything past registration as unconfirmed:
Host header are rejected at the Cloudflare edge with 403, so the custom-host serving path cannot be exercised without controlling the domain's DNS. No page was ever fetched over a custom hostname, and certificate issuance was not observed./v2/internal/domain-check nor /internal/domain-check exists on api2.infiniteocean.io — both return 404. If your certificate issuance depends on such an endpoint, it is not there today.sites/example.com/blog maps to a path under the custom hostname was not measured, only the shared-host form above.Register the domain and publish the route by all means — those calls work. Confirm certificate issuance and hostname serving with InfiniteOcean before you point production DNS at it.
Publishing does not change the API view of the data. Both of these need your key, and both list entities the public host will not serve.
curl -s "https://api2.infiniteocean.io/v2/list/_docs_probe%2Fsitedemo?limit=20" \
-H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"items":[
{"key":"_hidden","ts":1787593207998,"value":{"_body":"<p>secret</p>"}},
{"key":"about","ts":1787592742098,"value":{"_body":"<!doctype html><h1>About</h1><p>Second page.</p>"}},
{"key":"feed","ts":1787592806926,"value":{"_body":"{\"items\":[1,2,3]}","_content_type":"application/json"}},
{"key":"feed.json","ts":1787592832854,"value":{"_body":"{\"items\":[1,2,3]}"}},
{"key":"index","ts":1787592794758,"value":{"_body":"<!doctype html><html><head>...</html>"}},
{"key":"plaindata","ts":1787592776230,"value":{"title":"not web content","n":1}},
{"key":"style.css","ts":1787592768686,"value":{"_body":"body{font-family:system-ui;max-width:40rem;margin:4rem auto}"}}
],"returned":7,"complete":true,"index":true,"next_cursor":null}
(The index value is abbreviated above; everything else is verbatim.)
curl -s "https://api2.infiniteocean.io/v2/entity/_docs_probe%2Fsitedemo/about" \
-H "X-Ocean-Key: $OCEAN_KEY"
{"_body":"<!doctype html><h1>About</h1><p>Second page.</p>"}
/v2/list/ rejects unknown query parameters the same way /v2/access/ does — ?published=true returns 400 UNKNOWN_PARAM. There is no query parameter that filters by published state.
index entity with payload._body. Check "rule_created":true on the first write.about.html, style.css, data.json.PUT /v2/access/{route}/shares/%40view and exactly {"perm":"read"}. Never send read-write._body in the published route is public.DELETE .../shares/%40view — not an expires_at_ms, which does not stop serving. Expect edge-cached static assets to outlive the unpublish.A page you publish is served from more than one machine, and the other machines do not necessarily have it the instant you write it. Two things are worth knowing before you rely on it.
The write reaching storage and every node serving it are different events. Storage is immediate. Nodes catch up afterwards, and while a node is behind, the request still succeeds: you receive HTTP 200 and a complete, valid page — the previous one. It is not a fallback, not a degraded mode, and not an error being suppressed. Nothing on the serving side considers anything to have gone wrong, so nothing reports it.
We are not publishing a catch-up time. Any figure we printed would be read as a promise, and the honest position is that we have not measured this often enough to make one. If a customer is about to look at something you have just published, verify it rather than assuming — the per-node check below takes seconds.
Requesting the public URL repeatedly tells you less than it appears to, and a cache-busting query string tells you least of all: it changes the URL, not which server answers. To see what is actually happening, ask each node directly:
curl -sk --resolve yourdomain.example:443:<node-ip> https://yourdomain.example/page/
Mind the trailing slash — it changes what you are asking for.
/page and /page/ do not resolve to the same thing: one is a
key on the parent route, the other addresses the sub-route. Two people comparing
results, one with a trailing slash and one without, are measuring different objects
and will disagree for reasons that have nothing to do with propagation.
Nodes disagreeing after a publish is expected and is not by itself a fault. What is worth reporting is a difference that does not resolve once the route sees traffic again, or a page that is out of date on every node.
https://api2.infiniteocean.io · authenticate with the X-Ocean-Key header.