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​

All four runtimes accept one. A body that is not JSON is a normal event, not an error.

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. It reaches your handler as event["data"], whatever language you wrote it in. Bytes that are not valid text arrive instead as event["data_base64"], still encoded, for you to decode — because there is no lossless text form for them and handing you mangled characters would be worse than handing you the bytes.

This used to be Python only, and several pages still say so

Until D0144 the Node.js, Go and Ruby runtimes answered 400 invalid JSON body here, which meant an object-store drop never reached those handlers at all and nothing appeared in the function's logs. That is fixed, and every runtime has a test pinning it. If you find a page telling you to write bucket-triggered functions in Python, that text is out of date.

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. The envelope's attributes are attached to the event as event["_cloudevent"], on all four runtimes. 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.

The shim attaches those attributes as event["_cloudevent"] — on all four runtimes, with the same keys:

  • 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 {}

One limit 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 of the two failures.

This used to be Python only, and this page used to say so

Until D0144 the Node.js, Go and Ruby runtimes really did discard the attributes, and those three also answered 400 to any body that was not JSON. Both gaps were closed, and several pages went on describing them — including this one. If you read anywhere that attributes or non-JSON bodies are a Python-only capability, that text is out of date: all four runtimes behave the same way now, and TestTheFourShimsAgreeOnTheEventKeys fails the build if one of them drifts again.

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.net

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

Request headers​

The event says what was sent. event["_headers"] says who sent it, and how.

def handle(event):
headers = event.get("_headers", {})
print(headers.get("user-agent"))
return {"tenant": headers.get("x-tenant-id")}

It is a plain map of the request's headers, on all four runtimes, and it is present whether the request was a plain GET, a plain POST, or a CloudEvent delivery.

Three rules, and they are the whole contract:

  • The names are lowercased. Read event["_headers"]["content-type"], never ["Content-Type"]. This is deliberate rather than incidental: the four runtimes disagree about header case and always will, so lower case is the one spelling a handler can write once and have work everywhere.
  • A header sent more than once is joined with ", ". A map cannot hold two entries under one name, and keeping only the first would silently drop half of an Accept or a Forwarded chain.
  • Your payload wins. If the body you send already has a _headers key, it is kept and the request's headers are not attached — the same rule, for the same reason, as _cloudevent.

What it is for​

A GET-invoked function used to receive a literally empty event. Everything a caller told you — the content type, the tenant, the trace id, the webhook signature — was on the wire and none of it reached your code. Three things become possible:

  • Verifying a webhook. x-hub-signature-256, stripe-signature and the rest arrive intact, so a handler can check a signature before trusting a body. The Node.js webhook fan-out tutorial reads event["_headers"] for exactly this.
  • Telling callers apart on a shared function, with your own header.
  • Following a request through your own logs, with x-request-id or traceparent.

One header is removed​

x-cai-internal arrives with its value replaced by [redacted]. It is a platform credential — the platform attaches it to workloads it calls, and your code is not meant to hold it. The name is still there so that "the caller sent nothing" and "the platform removed it" are different things in your handler rather than the same absence.

Nothing else is removed. In particular authorization, cookie and x-cai-key-id all arrive intact, because they are yours: authorization is what a shared-secret webhook or a gateway jwt endpoint sends you, and x-cai-key-id is what the gateway adds so you can tell which key called.

A header can be a credential, and logging the event logs it

print(event) in a handler writes the caller's authorization header into your function's logs, where anyone who can read logs can read it. Log the keys you need, not the whole event.

Turning it off​

_headers is attached by default. A handler that round-trips the whole event — storing it, hashing it, or validating it against a schema that rejects unknown keys — sees a new key appear under it. Set the environment variable to restore the old shape:

platformctl agents env set my-function CRUSOE_EVENT_HEADERS=off

That rolls a new revision, because environment is baked in at deploy time. on, 1, true and yes all mean on, which is also what an unset value means; off, 0, false and no mean off. Anything else is treated as on and logged, so a typo cannot silently hide your data.

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​