Skip to main content

HTTP and events

This page is the full request and response contract for functions. It covers what your handler's event holds for each kind of request, and how your return value turns into the HTTP response. It also covers how a CloudEvent delivery behaves differently from a plain HTTP call.

How a request becomes an event

Every function sits behind a small web server the platform provides, called the shim. The shim maps requests to your handler like this:

RequestWhat your handler gets
GET /healthzNothing — the shim answers 200 with {"status": "ok"} itself. This is the platform's health check.
Any other GETAn empty {} event.
POST with a JSON object bodyThe parsed JSON body as the event.
POST with a body that is not JSONPython: {"data": "<the body as text>"}, or {"data_base64": "<base64>"} if the bytes are not valid UTF-8. Node.js, Go, and Ruby: rejected with 400 before your handler runs.
POST with a JSON scalar or listPython: wrapped as {"data": ...}. Node.js and Ruby: passed through unwrapped, so your handler receives a value that is not a dictionary. Go: rejected with 400.
POST that is a CloudEventThe event data — see CloudEvents below.

Your handler sees only the body. It never receives the URL path or the query string, and the only headers that reach it are the CloudEvent attributes described in CloudEvent attributes below. So you have two options if you need one workload to answer on several paths. Deploy a separate function per path, or use a serverless container service, where you control the whole web server yourself.

A body that is not JSON

Only the Python runtime accepts one. The other three parse the body as JSON before your handler is reached, and a body that will not parse is answered with 400:

{"error": "invalid JSON body: ..."}

The text after invalid JSON body: is that runtime's own parser message, so it differs between Node.js, Go, and Ruby.

This is the shape an object-store trigger delivers: the body is the object's own bytes, so a dropped markdown file, CSV, or log line arrives as text rather than as JSON. On Python it reaches your handler as event["data"]. On Node.js, Go, and Ruby the same drop never reaches your handler at all — the shim answers 400, the poller treats that as a permanent failure and does not retry, and nothing appears in your function's logs because your code never ran. Write that function in Python, or have the producer write JSON into the bucket.

The 8 MiB event cap

A POST body may be at most 8 MiB. A larger body is rejected before your handler runs, with status 413:

{"error": "body exceeds 8388608 bytes"}

The Go runtime answers the same 413 with a different body:

{"error": "body too large or unreadable"}

On the Python runtime, a request whose Content-Length header is not a number is rejected with status 400:

{"error": "invalid Content-Length"}

The Node.js, Go, and Ruby shims never read the Content-Length header at all. They measure the body as it arrives, or after reading it, and reject it with 413 only when it really is too large.

Uploading your function's code is a separate limit: up to 100 MiB through the CLI. See limits.

How your return value becomes the response

  • The whole return value becomes the JSON response body.
  • The status defaults to 200. Include a "statusCode" key to change it. So {"statusCode": 404, "error": "no such record"} produces an HTTP 404 whose body is that same JSON. The runtimes page shows this convention in each language.
  • If your handler throws an exception, the platform catches it and answers 500. Callers get a real response instead of a dropped connection:
{"error": "handler raised: division by zero"}

The text after handler raised: is your language's own error message — check the function's logs for the full stack trace.

Invoke through the platform

You can call the function's URL directly, as above. You can also call it through the platform's shared invoke path, the same one agents use:

platformctl invoke my-function "some text"

You should see a blank line and a session id — the invoke envelope reports an agent's output field, and a function's return value has no output key:


(session: 3f6c1f0e-9c1c-4f1a-8f2e-2a0d5f6b7c81)

However you call it, the invoke path delivers your text to the handler as event["message"]. Only session_id, user_id, message, and memorize survive the trip — the endpoint decodes the body into a fixed shape and re-sends that, so any other key you add is dropped before your handler sees it. Call the function's own URL when you need to deliver an arbitrary body. See invoking agents for the endpoint details.

The invoke path requires authentication by default

Deploying and managing functions always requires authentication, and so does calling one through the invoke path. INVOKE_AUTH_REQUIRED defaults to true, so send a bearer token. Without one you get 401:

{"error": "this platform requires authentication to invoke agents (INVOKE_AUTH_REQUIRED=true). Sign in (POST /v1/auth/login) and send 'Authorization: Bearer <token>'"}

This gates invoke, invoke-stream, and memorize — the data plane. A platform administrator can open it with INVOKE_AUTH_REQUIRED=false, but that opens every agent and function in every project at once: name resolution is not project-scoped, so an anonymous caller can invoke a workload it has nothing to do with. To publish one function publicly, put it behind the gateway with auth mode none and a rate limit instead. See API authentication.

CloudEvents delivery

A CloudEvent is a small, industry-standard envelope for event data. It says what happened, where, and when. Event systems use it to deliver messages to whoever subscribed. The shim treats a CloudEvent differently from a plain HTTP call, no matter who sent it.

A request counts as a CloudEvent if either of these is true:

  • It carries a Ce-Id header. That is binary mode: the labels ride in headers and the event data is the body as-is.
  • Its content type is application/cloudevents+json. That is structured mode: the whole envelope is the body, and your handler receives only its data field.

Four rules change for CloudEvents:

  1. The response is always an empty 204. That is the acknowledgement the sender expects, often shortened to "ACK". Your handler's return value is thrown away. An event handler runs for what it does — writing to a database, logging, calling another service — not for what it returns.
  2. Handler exceptions return 400, not 500. A 400 tells the sender this event failed permanently, so it stops trying. A 5xx would say "try again", and every retry would fail the same way forever. The body has the same shape either way: {"error": "handler raised: ..."}.
  3. On the Python runtime, the envelope's attributes are attached to the event as event["_cloudevent"]. See below.
  4. Nothing else changes. The 8 MiB cap still applies, and the event still reaches the same handle(event) function.

CloudEvent attributes

The event data alone often does not say what the event was about. An object-store trigger is the clearest case: the body is the object's bytes, so without the envelope your handler holds a file's contents and cannot say which file it is.

On the Python runtime, the shim attaches those attributes as event["_cloudevent"], a dictionary:

  • Binary mode — every ce-* request header, with the ce- prefix stripped and the name lowercased. A Ce-Subject: a.md header becomes event["_cloudevent"]["subject"].
  • Structured mode — every key of the envelope except data and data_base64. For a Pub/Sub push that is specversion, type, source, id, time, subject (the topic name), datacontenttype, subscription, deliveryattempt, and attributes when the message carries any.

Every attribute is passed through rather than a chosen few, because the names differ by source. Read the key you need defensively — event.get("_cloudevent", {}).get("subject") — rather than assuming a source populates it:

def handle(event):
ce = event.get("_cloudevent", {})
print(f"{ce.get('type')} from {ce.get('source')}")
return {}

Two limits worth knowing before you build on this. If your payload already has a _cloudevent key of its own, it wins and the attributes are not attached — losing a caller's data to platform metadata is the worse failure. And the Node.js, Go, and Ruby runtimes do not attach attributes at all; on those, event["_cloudevent"] is simply absent.

Try it with curl

Both examples post to the function's own address, so set that first. It is the public_url from GET /v1/agents/{name}, and the function has to be published for it to answer — see call it at its public URL.

export FN_URL=https://my-function-ab12cd.apps.codyhill.dev

Binary mode — the Ce-Id header marks it as a CloudEvent:

curl -i -X POST "$FN_URL" \
-H 'Ce-Id: 1234' \
-H 'Ce-Specversion: 1.0' \
-H 'Ce-Type: demo.event' \
-H 'Ce-Source: docs-example' \
-H 'Content-Type: application/json' \
-d '{"message": "an event happened"}'

You should see (headers abridged):

HTTP/1.1 204 No Content

Structured mode — the envelope is the body, and the handler receives only the data field (here, {"message": "an event happened"}):

curl -i -X POST "$FN_URL" \
-H 'Content-Type: application/cloudevents+json' \
-d '{"specversion": "1.0", "id": "1234", "type": "demo.event",
"source": "docs-example", "data": {"message": "an event happened"}}'

You should see:

HTTP/1.1 204 No Content

A 204 only tells you the delivery was accepted. To confirm the handler actually ran, read the persisted logs — they survive scale-to-zero, and a function that answered a single event is usually back at zero by the time you look:

platformctl logs my-function --history

What about schedules and other triggers?

Your function itself only ever sees an HTTP request. What decides when that request arrives can be a trigger, a separate object that fires a workload for you. A trigger fires on a repeating schedule, on a Pub/Sub message, or when an object appears in a bucket.

Triggers live on the Serverless API rather than on functions, but a trigger can target a function. Leave target.path at its default of /, which is where the function shim answers. Your handler then receives each delivery as a POST carrying the payload you configured, unchanged.

That delivery arrives with a ce-id header, so by the rules above it is a CloudEvent: your function answers 204, its return value is discarded, and a raised exception becomes a 400 that the trigger treats as permanent and never retries.

Fire my-function every 15 minutes:

platformctl serverless triggers create quarter-hourly \
--target my-function \
--type schedule \
--cron '*/15 * * * *' \
--payload '{"job":"sweep"}'

Set source.schedule.time_zone (--time-zone) if the schedule matters to a human. Leave it out and the schedule runs in UTC.

The triggers page documents the exact request a trigger sends, and what your status code does to retries.

For a worked example of wiring events to functions, see the event-driven functions tutorial; for the messaging service itself, see Pub/Sub.

Summary

SituationStatusBody
GET /healthz200{"status": "ok"} (shim answers)
GET anything elseyour statusCode (default 200)your return value as JSON
POST with JSON object bodyyour statusCode (default 200)your return value as JSON
POST body that is not JSON (Python runtime)your statusCode (default 200)your return value as JSON — handler gets {"data": ...}
POST body that is not JSON (Node.js, Go, Ruby)400{"error": "invalid JSON body: ..."} — handler never runs
POST body over 8 MiB413{"error": "body exceeds 8388608 bytes"} (Go runtime: {"error": "body too large or unreadable"})
Bad Content-Length (Python runtime only)400{"error": "invalid Content-Length"}
Invoke path with no bearer token401{"error": "this platform requires authentication to invoke agents ..."}
Handler exception (plain HTTP)500{"error": "handler raised: ..."}
CloudEvent delivered successfully204empty — return value discarded
Handler exception (CloudEvent)400{"error": "handler raised: ..."} — stops redelivery

Next steps