InfiniteOceanv2

Cron and functions

How to store and run Python on a route with /v2/run, call the three built-in operations on /v2/exec, and register schedules on /v2/cron — including what cron actually does today and where the injected ocean helper will lose your data.

What this page covers

Three doors on the InfiniteOcean v2 API let you run code and schedule work:

Base URL for every example on this page: https://api2.infiniteocean.io. Every request needs an X-Ocean-Key header. Examples write $OCEAN_KEY; substitute your own key.

Two things on this page will cost you time if you skip them: the injected ocean helper writes to a different product, not to v2, and cron's repeat behaviour depends on which kind of key registered the job.

Authentication

All three doors take the key in a header:

-H "X-Ocean-Key: $OCEAN_KEY"

Missing key on any of them returns 401:

$ curl -s -X POST https://api2.infiniteocean.io/v2/exec \
    -H "Content-Type: application/json" -d '{"op":"echo"}'
{"ok":false,"error":"X-Ocean-Key required","code":"KEY_REQUIRED"}

A key that is well-formed but not recognised returns 401 UNKNOWN_KEY.

Derived keys are rejected on these doors

App keys (POST /v2/appkeys) and sub-keys (POST /v2/subkeys) authenticate on GET /v2/whoami but are refused on /v2/run/, /v2/exec and GET /v2/cron. Both key kinds behave identically:

$ curl -s https://api2.infiniteocean.io/v2/whoami -H "X-Ocean-Key: $APP_KEY"
{"ok":true,"pid":"9a92e0aed98f","key_kind":"app_key",
 "app_key":{"id":"033ac23e06e6","name":"docs-verify-scoped2",
            "routes":["_docs_probe/other"],"can_read":true,"can_write":true},
 "scope":{"routes":["_docs_probe/other"]}}

$ curl -s -X POST https://api2.infiniteocean.io/v2/run/_docs_probe/greet \
    -H "Content-Type: application/json" -H "X-Ocean-Key: $APP_KEY" -d '{}'
{"ok":false,"error":"unknown key — not authenticated — nothing was run","code":"UNKNOWN_KEY"}

POST /v2/cron is the exception — it accepts an app key and creates the job. But GET /v2/cron with that same app key returns 401, so a job created with an app key cannot be listed with it. This is not a cosmetic difference: see What was measured, where the two key kinds produced different firing behaviour.

Running stored code — POST /v2/run/{route}

A "function" is Python source stored under the reserved key __code on a route you own. POST /v2/run/{route} loads that source, runs it, and returns whatever your main() returned.

Step 1 — store the code

Write a normal drop with key __code. The payload must be an object with the source in src (or code):

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/greet","key":"__code",
       "payload":{"lang":"python",
                  "src":"def main(ocean, request):\n    name = request.get(\"name\", \"world\")\n    return {\"greeting\": \"hello \" + name}\n"}}'
{"ok":true,"seq":1787577342970358,"offset":0,"len":213,"ts":1787595645410,
 "state":"accepted","rule_created":false,"drop_id":"1787577342970358",
 "route":"_docs_probe/greet","key":"__code"}

rule_created is true the first time you write to a route (the write claims it for your account) and false on every write after that. The example above is a rewrite of an existing route, so it shows false.

A plain string payload does not work. "payload": "def main(...)..." is stored as a JSON string and is handed to Python as a string literal, so there is no main and the run returns 422 no main() defined. Always use the object form.

A newly written function is not runnable on every node at once

The five nodes behind api2.infiniteocean.io do not see a write simultaneously. Immediately after storing __code on a fresh route, two nodes returned 404 NO_CODE while three served the function — same route, same second:

$ # write __code to _docs_probe/vx1787595658, then run it on each node
  de:  {"ok":false,"error":"no stored function (__code) on this route — nothing was run","code":"NO_CODE"}
  fi:  {"ok":false,"error":"no stored function (__code) on this route — nothing was run","code":"NO_CODE"}
  ash: {"ok": true, "result": {"ok": 1}, "stdout": "", "ms": 0}
  hil: {"ok": true, "result": {"ok": 1}, "stdout": "", "ms": 0}
  sin: {"ok": true, "result": {"ok": 1}, "stdout": "", "ms": 1}

The window is short. Polling the slowest node, the function became runnable 8 seconds after the write. Through the public endpoint, where the load balancer picks a node for you, six of six runs issued immediately after the write succeeded — so you may never see it. Retry on NO_CODE rather than treating it as permanent.

Step 2 — run it

The request body is passed to your function as request. An empty body becomes {}. The body does not have to be an object — an array or a bare string is passed through as-is.

curl -s -X POST https://api2.infiniteocean.io/v2/run/_docs_probe/greet \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"name":"Ada"}'
{"ok": true, "result": {"greeting": "hello Ada"}, "stdout": "", "ms": 0}

The response fields are:

FieldMeaning
oktrue if main() returned; false if it raised or was missing.
resultThe value your main() returned. Present only when ok is true.
errorThe exception message. Present only when ok is false.
tracebackPython traceback. Present when main() raised.
stdoutEverything the function printed, captured as a string.
msWall time of your function body, in milliseconds.

The function contract

Your source must define main. Two signatures are accepted:

def main(ocean, request):          # request = the JSON body you POSTed
    ...

def main(ocean, request, context):  # context is also passed
    ...

context is delivered but is always empty on v2. Measured:

$ curl -s -X POST https://api2.infiniteocean.io/v2/run/_docs_probe/ctx \
    -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" -d '{"a":1}'
{"ok": true, "result": {"context": {}, "request": {"a": 1}}, "stdout": "in stdout\n", "ms": 0}

Do not read request headers, method or caller IP out of context — put anything you need in the request body.

print() is captured into stdout rather than lost, as shown above.

Only Python runs

The lang field is accepted and stored but does not select a runtime. Source is always compiled as Python. A JavaScript function stored with "lang":"javascript" fails with a Python SyntaxError:

{"ok": false, "error": "invalid syntax (<function>, line 1)",
 "traceback": "Traceback (most recent call last):\n  File \"/app/runner.py\", line 286, in _run_in_child\n    compiled = compile(code, '<function>', 'exec')\n               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"<function>\", line 1\n    export function main(){ return {js:true} }\n           ^^^^^^^^\nSyntaxError: invalid syntax\n"}

A function that raises

An exception inside main() is not a server error. You get HTTP 422 with the message and traceback:

$ curl -s -X POST https://api2.infiniteocean.io/v2/run/_docs_probe/boom \
    -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" -d '{}'
{"ok": false, "error": "bad input",
 "traceback": "Traceback (most recent call last):\n  File \"/app/runner.py\", line 302, in _run_in_child\n    result = scope['main'](ocean, request)\n             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"<function>\", line 2, in main\nValueError: bad input\n",
 "stdout": "", "ms": 0}

HTTP status: 422. Source with no main also returns 422, with "error": "no main() defined".

A malformed request body returns 503, not 400

If the body you POST is not valid JSON, the door forwards it and the runner refuses it. You get 503 RUNNER_UNAVAILABLE — an error whose text blames the runner but whose cause is your body:

$ curl -s -X POST https://api2.infiniteocean.io/v2/run/_docs_probe/ctx \
    -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" -d 'oops'
{"ok":false,"error":"could not complete POST to runner /exec (transport or runner not answering) — nothing was run","code":"RUNNER_UNAVAILABLE"}

Reproduced three times in a row, with a valid body succeeding immediately afterwards. If you see 503 RUNNER_UNAVAILABLE, check that your request body is valid JSON before concluding the platform is down.

Configuring a function — __fn_config

Store a second drop with key __fn_config on the same route:

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/sleep8","key":"__fn_config","payload":{"timeout_ms":20000}}'
FieldTypeDefaultNotes
timeout_msinteger5000Maximum accepted value 60000. A value above that is ignored, not clamped — see below.
memory_mbinteger128Accepted and stored. It did not constrain a function that allocated 200 MB — see below.
executestring"private""private" requires write access to the route. "public" is accepted and stored; its cross-account effect was not verified. An unauthenticated call is refused either way.
langstring"python"Accepted but does not change the runtime.

__fn_config is optional. A route with only __code runs with the defaults.

timeout_ms above 60000 silently falls back to the 5000 default

This is the one configuration mistake most likely to bite you. Three copies of the same function — time.sleep(8) then return — differing only in __fn_config:

timeout_msResultElapsed
none (default 5000)504 RUN_TIMEOUT7.3 s
60000200, {"slept": 8}10.0 s
90000504 RUN_TIMEOUT7.3 s

90000 is above the maximum, so the value is discarded and the function runs with the 5000 ms default — the opposite of what you asked for. Keep timeout_ms in the range 1–60000.

memory_mb did not enforce a limit

A function that allocates a 200 MB bytearray succeeded both with no config (default 128) and with memory_mb: 128 set explicitly:

$ # __fn_config = {"memory_mb":128,"timeout_ms":30000}
{"ok": true, "result": {"allocated_mb": 200, "len": 209715200}, "stdout": "", "ms": 141}

So memory_mb is stored but did not bound this function. Do not rely on it as a guard, and do not assume the 128 MB default is a ceiling you are safe under. Where the real limit sits was not probed.

Calling the API from inside a function

Your function receives an object named ocean. Do not use its convenience methods. They do not address the v2 API. Introspecting the object shows why:

{"ok": true, "result": {
  "attrs": ["base","bulk_write","export_entities","fallback","key","query",
            "read","run","search","sql","upload","write"],
  "vals": {"base": "'http://127.0.0.1:3004'",
           "fallback": "'https://api.infiniteocean.io'",
           "key": "STR len=67", "read": "callable", "write": "callable"}}}

base is a loopback address the sandbox cannot reach, and fallback is api.infiniteocean.io — a different product from the one you are calling. The consequences are not cosmetic:

$ # a function calling ocean.read / ocean.write / ocean.query / ocean.search
{"ok": true, "result": {
  "write":  "{'drop_id': '372447ad-57a0-42a5-bcab-9a40bf6c7b07', 'route': '_docs_probe/automation', ...}",
  "read":   "OceanError: 404 entity not found",
  "query":  "{'results': [], 'scanned': 0, 'matched': 0, 'returned': 0, ...}",
  "search": "{'results': [{'drop_id': '372447ad-57a0-42a5-bcab-9a40bf6c7b07', ...}]}"}}

Read that write result carefully. It looks like a success. It returned a drop id. But the drop is not on v2:

$ curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/automation/ocean-write-probe \
    -H "X-Ocean-Key: $OCEAN_KEY"
{"ok":false,"error":"entity not found","code":"NOT_FOUND"}

And ocean.read() raised 404 for _docs_probe/automation @note1, a key that demonstrably exists on v2. ocean.write() silently discards data as far as your v2 account is concerned.

What to do instead

Call https://api2.infiniteocean.io over HTTPS with urllib. The caller's key is available as ocean.key, so you do not have to embed a credential in your source:

import json, urllib.request

def main(ocean, request):
    url = "https://api2.infiniteocean.io/v2/entity/_docs_probe/automation/note1"
    req = urllib.request.Request(url, headers={"X-Ocean-Key": ocean.key})
    with urllib.request.urlopen(req, timeout=8) as r:
        return {"status": r.status, "body": json.loads(r.read())}
{"ok": true, "result": {"status": 200, "body": {"title": "first note", "count": 3, "done": true}},
 "stdout": "", "ms": 1630}

Writing works the same way — a function that POSTs to /v2/drop with ocean.key returned {"wrote": "invoked-1787595180502", "status": 200}, and that drop was readable on v2 afterwards.

Budget for the round trip. Five runs of the read function above reported ms of 511, 1345, 1630, 1887 and 1995 — comfortably inside the 5 s default. But an outbound call is not reliably fast: one run of a similar function exceeded the default and returned 504. Raise timeout_ms whenever your function calls out.

Timeouts

When a run exceeds its timeout the origin returns HTTP 504 with:

{"ok":false,"error":"execution timed out — nothing durable was written by the runner","code":"RUN_TIMEOUT"}

Through the public endpoint the status is still 504, but the body is replaced with the CDN's plain-text error page (error code: 504), not JSON. Treat any 504 from /v2/run/ as a timeout and do not try to parse the body.

"Nothing durable was written by the runner" refers to the runner's own result. Anything your function already wrote over HTTPS before it was cut off has been written.

Errors — POST /v2/run/{route}

StatuscodeWhen
400BODY_TOO_LARGERequest body over 256 KiB. Verified with a 300 KB body.
400UNKNOWN_PARAMAny query string at all. ?x=1 returns unknown query parameter "x" — nothing was applied.
401KEY_REQUIREDNo X-Ocean-Key header.
401UNKNOWN_KEYKey not recognised — including app keys and sub-keys.
404NO_CODENo __code on the route, or the node serving you has not seen the write yet.
404NOT_FOUNDWrong method. GET /v2/run/{route} returns no /v2 endpoint matches this method and path.
422(runner body)Your function raised, or defined no main. Body carries error and traceback.
503RUNNER_UNAVAILABLEReached by sending a body that is not valid JSON. Check your body first.
504RUN_TIMEOUTExceeded timeout_ms. Body is not JSON through the public endpoint.

Built-in operations — POST /v2/exec

/v2/exec is not a door for arbitrary code — that is /v2/run/. It exposes exactly three operations. Every request must carry an op field:

$ curl -s -X POST https://api2.infiniteocean.io/v2/exec \
    -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" -d '{}'
{"ok":false,"error":"body must include op — nothing was run","code":"MISSING_FIELD"}

Anything outside the allowlist is rejected by name:

$ curl -s -X POST https://api2.infiniteocean.io/v2/exec \
    -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" -d '{"op":"deploy"}'
{"ok":false,"error":"op not on allowlist (echo|ping_store|save) — nothing was run","code":"OP_NOT_ALLOWED"}

The error text lists three names; write is also accepted, as an alias for save.

op: echo

Returns args back to you unchanged. Useful as a live check that your key and the door both work, with no side effects.

Fields: op (required), args (optional, any JSON value; defaults to {}).

$ curl -s -X POST https://api2.infiniteocean.io/v2/exec \
    -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
    -d '{"op":"echo","args":{"hello":"world","n":3}}'
{"ok":true,"op":"echo","args":{"hello":"world","n":3}}

args does not have to be an object:

$ ... -d '{"op":"echo","args":[1,"two",{"three":3}]}'
{"ok":true,"op":"echo","args":[1,"two",{"three":3}]}

op: ping_store

Asks whether a key exists on a route, and how many bytes its stored value occupies. It does not return the value.

Fields: op, route (required, string), key (required, string).

$ curl -s -X POST https://api2.infiniteocean.io/v2/exec \
    -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
    -d '{"op":"ping_store","route":"_docs_probe/automation","key":"note1"}'
{"ok":true,"op":"ping_store","found":true,"len":44}

A missing key is 200 with found:false, not a 404:

$ ... -d '{"op":"ping_store","route":"_docs_probe/automation","key":"nope"}'
{"ok":true,"op":"ping_store","found":false}

Read access is checked. A route your key cannot read — including one nobody has claimed — returns 403:

{"ok":false,"error":"forbidden","code":"FORBIDDEN"}

Omitting route or key is 400 MISSING_FIELD with ping_store requires route — nothing was run / ping_store requires key — nothing was run. Passing a non-string for either is 400 INVALID_INPUT, route and key must be strings — nothing was run.

op: save (alias: write)

Writes a drop using a one-line command string instead of a JSON body. The effect is the same as POST /v2/drop.

Fields: op ("save" or "write"), query (required, string).

The grammar is a single statement:

save <route> @<key> <field>: <value> [<field>: <value> ...]
save <route> @<key> { ...json object... }
save <route> @<key> "a string body"

Field values may be quoted strings, numbers, true, false or null. The @key is required — there is no auto-generated key.

$ curl -s -X POST https://api2.infiniteocean.io/v2/exec \
    -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
    -d '{"op":"save","query":"save _docs_probe/automation @note1 title: \"first note\" count: 3 done: true"}'
{"ok":true,"op":"save","route":"_docs_probe/automation","key":"note1","scope":"dsl_save_only"}

Reading it back with GET /v2/entity/{route}/{key}:

{"title":"first note","count":3,"done":true}

The JSON-object form stores the object as given:

$ ... -d '{"op":"save","query":"save _docs_probe/automation @note2 {\"nested\":{\"ok\":true},\"list\":[1,2,3]}"}'
{"ok":true,"op":"save","route":"_docs_probe/automation","key":"note2","scope":"dsl_save_only"}

$ curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/automation/note2 -H "X-Ocean-Key: $OCEAN_KEY"
{"nested":{"ok":true},"list":[1,2,3]}

The string form stores a bare JSON string as the payload:

$ ... -d '{"op":"save","query":"save _docs_probe/automation @note3 \"just a body\""}'
{"ok":true,"op":"save","route":"_docs_probe/automation","key":"note3","scope":"dsl_save_only"}

$ curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/automation/note3 -H "X-Ocean-Key: $OCEAN_KEY"
"just a body"

Using op:"write" is identical, and the response still reports "op":"save":

$ ... -d '{"op":"write","query":"write _docs_probe/automation @note4 x: 42"}'
{"ok":true,"op":"save","route":"_docs_probe/automation","key":"note4","scope":"dsl_save_only"}

save cannot create a route

Unlike POST /v2/drop, which claims an unowned route on first write, op:save only writes to a route you already own. On a route that has never been written, it returns 403:

$ ... -d '{"op":"save","query":"save _docs_probe/never_claimed_9f2 @k1 x: 1"}'
{"ok":false,"error":"forbidden","code":"FORBIDDEN"}

Claim the route once with POST /v2/drop, then op:save works on it.

Only save and write are understood

This is a single-verb command language. Any other verb is refused, with the supported scope named in the error:

$ ... -d '{"op":"save","query":"find _docs_probe/automation where count = 3"}'
{"ok":false,"error":"unsupported DSL — v2 exec slice is save|write only: save <route> @<key> fields|json|string; no new/find/run/every/share (scope=dsl_save_only)",
 "code":"UNSUPPORTED_DSL","scope":"dsl_save_only"}

A structurally invalid save — for example one with no @key — is a different error:

$ ... -d '{"op":"save","query":"save _docs_probe/automation x: 1"}'
{"ok":false,"error":"invalid save syntax — nothing was run","code":"INVALID_INPUT"}

Errors — POST /v2/exec

StatuscodeWhen
400MISSING_FIELDNo op; or op:save with no query; or ping_store without route/key.
400OP_NOT_ALLOWEDop is not echo, ping_store, save or write.
400INVALID_INPUTop is not a string; route/key not strings; unparseable save syntax.
400UNSUPPORTED_DSLquery uses a verb other than save/write. Response carries "scope":"dsl_save_only".
400INVALID_JSONBody is not valid JSON (body is not valid JSON — nothing was run) or is JSON but not an object (body must be an object — nothing was run).
400BODY_TOO_LARGEBody over 256 KiB.
400UNKNOWN_PARAMAny query string on the URL.
401KEY_REQUIRED / UNKNOWN_KEYNo key, or a key this door does not accept (app keys, sub-keys).
403FORBIDDENNo read access for ping_store, or no write access for save.
404NOT_FOUNDWrong method — GET /v2/exec is not routed.

Scheduling — /v2/cron

A cron job is a named schedule that writes a drop onto a target route. Read What was measured before you depend on it — repeat behaviour differed by key kind in testing.

POST /v2/cron — register a schedule

FieldTypeRequiredNotes
namestringyesThe job's identity, and what you pass to DELETE /v2/cron/{name}.
targetstringyesA route you have write access to. Fires write drops here.
every_msintegeryesInterval in milliseconds. Must be at least 100. Values below 5000 are clamped up.

There is no cron expression. Sending "schedule": "0 * * * *" is rejected with body must include every_ms (cron expressions later).

curl -s -X POST https://api2.infiniteocean.io/v2/cron \
  -H "Content-Type: application/json" -H "X-Ocean-Key: $OCEAN_KEY" \
  -d '{"name":"docs-verify-heartbeat","target":"_docs_probe/vticks","every_ms":60000}'
{"ok":true,"name":"docs-verify-heartbeat","every_ms":60000,"every_ms_requested":60000,
 "tick_floor_ms":5000,"target":"_docs_probe/vticks","owned_here":false}
Response fieldMeaning
every_msThe interval that will actually be used, after clamping.
every_ms_requestedWhat you asked for.
tick_floor_msThe shortest interval the scheduler will accept: 5000.
owned_hereWhether the node that answered this request is the one that owns firing the job.

owned_here is derived from the job's name, not from which node you reached. Posting the same name to each of the five nodes in turn returned true on exactly one:

$ # {"name":"docs-verify-own1","target":"_docs_probe/vticks","every_ms":30000} to each node
  de:  {"ok":true,...,"owned_here":false}
  fi:  {"ok":true,...,"owned_here":false}
  ash: {"ok":true,...,"owned_here":true}
  hil: {"ok":true,...,"owned_here":false}
  sin: {"ok":true,...,"owned_here":false}

Because the load balancer chooses a node for you, whether your registration returns owned_here:true is effectively chance. It matters: in testing, only jobs registered on the owning node produced a drop at registration time.

Requests below the floor are accepted, not rejected, and the clamp is reported honestly:

$ ... -d '{"name":"docs-verify-fast","target":"_docs_probe/vticks","every_ms":1000}'
{"ok":true,"name":"docs-verify-fast","every_ms":5000,"every_ms_requested":1000,
 "tick_floor_ms":5000,"target":"_docs_probe/vticks","owned_here":true}

Below 100 ms is a hard rejection. 100 is accepted (and clamped to 5000); 99 is not:

$ ... -d '{"name":"docs-verify-min99","target":"_docs_probe/vticks","every_ms":99}'
{"ok":false,"error":"invalid cron — nothing was applied","code":"INVALID_INPUT"}

Re-registering an existing name

POSTing a name that already exists returns 200 and takes the new values — it does not error. Re-registering docs-verify-vfn1 with a new every_ms and a new target was accepted, and a drop appeared on the new target; nothing further appeared on the old one.

$ ... -d '{"name":"docs-verify-vfn1","target":"_docs_probe/vticks","every_ms":45000}'
{"ok":true,"name":"docs-verify-vfn1","every_ms":45000,"every_ms_requested":45000,
 "tick_floor_ms":5000,"target":"_docs_probe/vticks","owned_here":true}

What a fire does

A fire writes one drop to the target route. It does not execute code — not even if the target route has a __code function stored on it.

The key is cron-{name}-{unix_ms} and the payload is:

$ curl -s https://api2.infiniteocean.io/v2/entity/_docs_probe/cronfn/cron-docs-verify-vfn1-1787595196930 \
    -H "X-Ocean-Key: $OCEAN_KEY"
{"event":"cron","name":"docs-verify-vfn1","at":1787595196930}

That drop came from a job whose target route held a working function. The function writes a marker drop to a second route when it runs, and it was confirmed to do so when invoked through /v2/run/. After the cron fire, no new marker appeared — the target route gained the cron-... drop and nothing else.

To act on a schedule, subscribe to the target route (for example with GET /v2/listen/{route}) or poll it, and call /v2/run/ yourself.

GET /v2/cron — list your schedules

No parameters — any query string is 400 UNKNOWN_PARAM. Returns jobs registered by your account.

$ curl -s https://api2.infiniteocean.io/v2/cron -H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"jobs":[{"name":"docs-verify-heartbeat","every_ms":60000,"every_ms_requested":60000,
 "tick_floor_ms":5000,"target":"_docs_probe/vticks","creator":"9a92e0aed98f"}]}

An empty result is {"ok":true,"jobs":[]}, not a 404. creator is the account id that registered the job; it is absent on jobs registered with an app key.

The list is not a reliable inventory

Nodes disagree about which jobs exist, and membership changes between polls. Querying all five nodes within the same few seconds, with the same key:

  de:  jobs=["docs-verify-heartbeat","docs-verify-vfn1"]
  fi:  jobs=["docs-verify-heartbeat","docs-verify-vfn1"]
  ash: jobs=["docs-verify-heartbeat","docs-verify-vfn1"]
  hil: jobs=["docs-verify-heartbeat"]
  sin: jobs=["docs-verify-heartbeat"]

Polling the public endpoint every ~15 s over three minutes, the same account saw the set change repeatedly — a job present at one poll was absent two polls later and a third job appeared later still. One registered job (docs-verify-fast) never appeared in any list on any node, yet it had already written a drop to its target.

Do not use GET /v2/cron to decide whether a job exists or is running. Track the names you registered yourself, and judge whether a job is working by watching its target route.

DELETE /v2/cron/{name} — stop a schedule

$ curl -s -X DELETE https://api2.infiniteocean.io/v2/cron/docs-verify-heartbeat \
    -H "X-Ocean-Key: $OCEAN_KEY"
{"ok":true,"name":"docs-verify-heartbeat","changed":true}

Always 200, whether the name existed or not. changed is not a reliable "the job existed" signal. Measured in one session:

Name deletedchangedElapsed
docs-verify-heartbeat — registered, present in the listtrue74.5 s
docs-verify-never-existed-qq7 — never registeredtrue87.8 s
docs-verify-fast — registered, had already fired, absent from the listfalse1.0 s

A name that never existed returned changed:true, so the field cannot tell you whether you deleted anything.

Deletes are slow. Two of the three above took over 70 seconds. Set a client timeout of at least 120 s on this call; a 90 s timeout aborted a delete that later completed.

DELETE /v2/cron with no name is not routed and returns 404 NOT_FOUND.

What was measured

Eight schedules were registered against fresh routes and watched by polling their target routes. Two behaviours separated cleanly, and the separating factor was which kind of key registered the job.

JobRegistered withevery_ms (effective)owned_hereWatchedFires observed
docs-verify-heartbeataccount key60000false~7 min0
docs-verify-min100account key5000 (asked 100)false~10 min0
docs-verify-acct-…account key30000false~7 min0
docs-verify-fastaccount key5000 (asked 1000)true14.4 min1, at registration
docs-verify-vfn1account key10000true~25 min1, at registration
docs-verify-own1account key30000true~5 min1, at registration
docs-verify-akapp key60000false~23 min15
docs-verify-appk-…app key30000false~7 min7

What follows from that, stated as observation rather than as intent:

  1. Jobs registered with an app key repeated. docs-verify-ak wrote 15 drops to its target. The two 30000 ms arms — docs-verify-acct-… and docs-verify-appk-… — were registered within seconds of each other against fresh routes and polled by the same loop, and produced 0 fires and 7 fires respectively, so this is not an artefact of different timing.
  2. Jobs registered with an account key did not repeat in any window tested. Where owned_here came back true they wrote exactly one drop, at registration, and never another — including one watched for 25 minutes at a 10 s interval, which would have been about 150 fires. Where owned_here was false they wrote nothing at all.
  3. The interval is a floor, not a promise. For a requested 60000 ms, gaps between consecutive fires were 56, 63, 66, 72, 83, 100, 105, 106, 108, 110, 111, 111, 116 and 146 seconds. For a requested 30000 ms: 48, 50, 50, 52, 54 and 86 seconds. Expect fires no more often than every_ms, and plan for gaps two to three times longer.

What to do with this. If you need a repeating schedule today, register it with an app key scoped to the target route, and verify it against the target route rather than against GET /v2/cron — remembering that the same app key cannot list what it created. If you need a guarantee, drive the work from your own scheduler and call /v2/run/{route}. Whether the account-key/app-key difference is intended, or a condition on the days it was measured, is not something the API tells you; treat it as an observation with a date on it, not a contract.

Errors — /v2/cron

StatuscodeWhen
400MISSING_FIELDMissing name, target or every_ms. Each names the field: body must include name, body must include target, body must include every_ms (cron expressions later).
400INVALID_INPUTevery_ms not an integer ("60000" as a string is rejected with every_ms must be an integer), or below 100 (invalid cron — nothing was applied).
400INVALID_JSONBody is not valid JSON, or is JSON but not an object (body must be an object).
400UNKNOWN_PARAMAny query string, on POST or GET.
401KEY_REQUIREDNo X-Ocean-Key, on any of the three methods.
401UNKNOWN_KEYGET /v2/cron with an app key or sub-key.
403FORBIDDENNo write access to target — including a route nobody has claimed yet.
403APPKEY_OUT_OF_SCOPEPOST with an app key whose route scope does not cover target: app key out of scope for this route or op.
404NOT_FOUNDDELETE /v2/cron with no name in the path.

Limits and known behaviour

LimitValueHow it surfaces
Request body, /v2/run/ and /v2/exec256 KiB400 BODY_TOO_LARGE
Function timeout, default5000 ms504 RUN_TIMEOUT
Function timeout, maximum accepted60000 msAbove this the value is discarded and the 5000 ms default applies
Function memorynot enforced in testing200 MB allocated successfully under a memory_mb of 128
Cron interval, minimum accepted100 ms400 INVALID_INPUT below it
Cron interval, effective floor5000 mstick_floor_ms in the response
Query parametersnone on any of these doors400 UNKNOWN_PARAM

Things to design around:

Base URL https://api2.infiniteocean.io · authenticate with the X-Ocean-Key header.