Event-driven functions
So far you've called functions yourself — you send a request, the function answers. In this tutorial the platform calls your function for you: every message published to a topic is delivered to your function automatically, retried if it fails, and parked in a dead-letter topic if it keeps failing.
You'll build a tiny order-processing pipeline: a topic called orders, a Python function called order-worker, and the wiring between them. Then you'll deliberately break the function to watch the retry and dead-letter machinery work. Budget about 20 minutes.
What you're building
A topic is a named channel you publish messages to. A subscription is a durable reader of that topic. A push subscription is one where the platform POSTs each message to an HTTP endpoint inside your project — which is exactly what a deployed function is.
Functions answer plain HTTP calls and CloudEvent deliveries. They have no trigger settings of their own. Separately, the Serverless surface has Trigger objects for schedules and object-storage activity — but its Pub/Sub trigger source currently reports Pending and is not usable in this alpha.
So the working path for "run my function when something happens" today is the one on this page: a Pub/Sub push subscription pointed at your function. There is no Knative Broker/Trigger surface exposed to customers.
Before you begin
- A platform account and a project — see Create an account.
platformctlbuilt, signed in, and pointed at your project — see Install the CLI.- The admin role on the project. Creating topics and subscriptions is admin-only; publishing and pulling need only
member. kubectlaccess to the cluster, so you can reach the Pub/Sub API. There is no public API hostname during alpha.
Set up a shell you'll use for the whole tutorial:
kubectl -n cai-system port-forward svc/pubsub-api 8080:8080 &
export API=http://localhost:8080
export CAI_TOKEN="paste an API key from the console: Security -> My API keys"
export PROJECT_ID="paste the ID column from: platformctl projects list"
CAI_TOKEN does double duty: curl sends it as a bearer token, and platformctl prefers it over every other credential.
1. Write and deploy the function
The handler reads an order out of the event, prints a line, and returns. Printing is how you'll prove it ran — the return value of a function that receives an event is discarded (more on that in step 7).
mkdir order-worker && cat > order-worker/handler.py <<'EOF'
def handle(event):
order_id = event.get("order_id", "unknown")
total = event.get("total", 0)
print(f"processing order {order_id} for {total}")
return {"ok": True}
EOF
platformctl functions deploy ./order-worker --name order-worker
You should see:
packaging ./order-worker...
uploading order-worker (0.3 KiB, framework=function)...
build 7c41d9e2-... accepted
status: -> building
status: building -> deploying
status: deploying -> ready
order-worker is ready at http://order-worker.cai-p-ab12cd.svc.cluster.local
Copy that last URL. It is your function's in-cluster address, and it is exactly what the push subscription needs. The cai-p-ab12cd part is your project's own Kubernetes namespace — every project gets one, named after the project's short id.
export FN_URL=http://order-worker.cai-p-ab12cd.svc.cluster.local # your URL from above
2. Create the two topics
orders carries the work. orders-dead catches messages that fail over and over. A dead-letter topic must already exist before a subscription can name it, and it must be a different topic from the one it protects — otherwise a poison message would be republished into the queue it just poisoned.
curl -sX POST "$API/v1/projects/$PROJECT_ID/topics" \
-H "Authorization: Bearer $CAI_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"orders","max_bytes":"16Mi","discard":"old"}'
You should see (trimmed):
{"name":"orders","path":".../topics/orders","max_bytes":"16Mi","discard":"old",
"address":"persistent://p-ab12cd/main/orders",
"state":{"phase":"Pending","ready":false,...}}
"phase":"Pending" is normal: the topic object exists immediately, and the message broker catches up a few seconds later. Now the dead-letter topic:
curl -sX POST "$API/v1/projects/$PROJECT_ID/topics" \
-H "Authorization: Bearer $CAI_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"orders-dead","max_bytes":"16Mi","discard":"old"}'
Each project has a 1 GiB Pub/Sub storage budget, and a topic claims its whole max_bytes as soon as it's created — even while empty. Two 16 Mi topics have spent 32 Mi. If a create fails with a message about the storage budget, delete a topic or lower another topic's max_bytes; consuming messages does not free budget.
3. Create a pull subscription on the dead-letter topic
Do this before publishing anything. A message is kept only while some subscription still owes an acknowledgement for it. Publish to a topic nobody is subscribed to and the message is accepted, given an id, and then quietly reclaimed.
curl -sX POST "$API/v1/projects/$PROJECT_ID/topics/orders-dead/subscriptions" \
-H "Authorization: Bearer $CAI_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"dead-watch","type":"shared","ack_deadline_seconds":30}'
You should see (trimmed):
{"name":"dead-watch","topic":"orders-dead","type":"shared",
"ack_deadline_seconds":30,"max_deliver":5,"start_from":"all",
"deliver":{"mode":"pull"},...}
4. Create the push subscription
This is the wiring. It says: deliver every orders message to the function, in CloudEvents binary form, and give up after three failed attempts by moving the message to orders-dead.
curl -sX POST "$API/v1/projects/$PROJECT_ID/topics/orders/subscriptions" \
-H "Authorization: Bearer $CAI_TOKEN" -H "Content-Type: application/json" \
-d "{\"name\":\"to-worker\",\"type\":\"shared\",
\"ack_deadline_seconds\":60,
\"max_deliver\":3,
\"deliver\":{\"mode\":\"push\",
\"push\":{\"url\":\"$FN_URL\",\"content_mode\":\"cloudevents-binary\"}},
\"dead_letter\":{\"topic\":\"orders-dead\",\"after_attempts\":3}}"
You should see (trimmed):
{"name":"to-worker","topic":"orders","type":"shared",
"ack_deadline_seconds":60,"max_deliver":3,
"deliver":{"mode":"push","push":{"url":"http://order-worker.cai-p-ab12cd.svc.cluster.local",
"content_mode":"cloudevents-binary"}},
"dead_letter":{"topic":"orders-dead","after_attempts":3},...}
Three choices in that body are worth understanding:
| Field | Why this value |
|---|---|
ack_deadline_seconds: 60 | On a push subscription the ack deadline doubles as the request timeout. Your function scales to zero when idle, so the first delivery has to pay for a cold start. 60 seconds is generous; the allowed range is 1–600. |
content_mode: cloudevents-binary | Binary mode puts your message payload in the request body and the event metadata in ce- headers. That means your handler's event is your JSON payload, parsed — nothing to unwrap. (Structured mode would wrap payload and metadata together in one envelope.) |
max_deliver and after_attempts both 3 | Fail three times, then move the message to orders-dead instead of retrying forever. |
deliver.push.url is strictly checked: http:// only, no redirects followed, and the host must be a Kubernetes Service in your project's own namespace — <service>.<namespace>, <service>.<namespace>.svc, or <service>.<namespace>.svc.cluster.local. A public URL, or a Service in someone else's namespace, is refused with a 400 that names the reason and shows the correct shape. This is deliberate: it means the platform can never be tricked into attacking a system for you.
5. Publish a message
The CLI is the easiest publisher. Because the payload is JSON, label it — otherwise it can arrive tagged as raw bytes.
platformctl pubsub topics publish orders \
--message '{"order_id":"A-1001","total":42}' \
--attribute datacontenttype=application/json
You should see a message id:
1234:0
That id is your proof of storage. Counters on dashboards lag by up to about five minutes; the publish response never does.
6. Confirm the function ran
Delivery happens within seconds — plus a cold start if the function was idle. Read the function's persisted logs:
platformctl logs order-worker --history
You should see a line like:
2026-08-12T18:04:11Z stdout processing order A-1001 for 42
Use --history rather than plain platformctl logs here: history survives scale-to-zero, while the live tail needs a running instance and your function has probably gone back to sleep.
That's the whole loop. Publish a few more messages and watch them appear.
7. What an event delivery does differently
Your function is the same code whether you call it over HTTP or the platform delivers an event to it, but three rules change for event deliveries:
- The response is always an empty
204, and your return value is discarded. The 204 is the acknowledgement the subscription is waiting for. Event handlers work by their side effects — writing to a store, calling another service, logging. - An exception answers
400, not500. For a plain HTTP call, a crash is a500. - Everything else is unchanged: the same
handle(event)function, the same 8 MiB body cap.
A push delivery counts as delivered only on a 2xx response. Anything else — an error status, or a timeout longer than the ack deadline — is a failed attempt, and the platform retries with exponential backoff: 1 second, then 2, then 4, capped at 60.
8. Watch a failure land in the dead-letter topic
Break the handler on purpose:
cat > order-worker/handler.py <<'EOF'
def handle(event):
raise RuntimeError("pretend the database is down")
EOF
platformctl functions deploy ./order-worker --name order-worker
Publish another order:
platformctl pubsub topics publish orders \
--message '{"order_id":"A-1002","total":99}' \
--attribute datacontenttype=application/json
The handler raises, the shim answers 400, the subscription retries — 1 second, then 2 — and after the third failed attempt the message is republished to orders-dead. Wait about fifteen seconds, then read it:
platformctl pubsub subscriptions pull dead-watch --topic orders-dead --max 10 --ack
You should see:
ID KEY DATA
5678:0 - {"order_id":"A-1002","total":99}
acknowledged 1 message(s)
The dead-lettered message also carries extra attributes whose names begin with cai-dead-letter-, recording why it ended up there. Use -o json on the pull to see them in full.
If a push subscription has no dead_letter configured, a message that exhausts its attempts is simply dropped — the only record is a line in the platform's own server log, which you cannot read. Configure a dead-letter topic for anything you cannot afford to lose, and put a subscription on it, or the dead letters vanish too.
Now put the working handler back:
cat > order-worker/handler.py <<'EOF'
def handle(event):
order_id = event.get("order_id", "unknown")
total = event.get("total", 0)
print(f"processing order {order_id} for {total}")
return {"ok": True}
EOF
platformctl functions deploy ./order-worker --name order-worker
9. Clean up
Deleting a topic deletes its subscriptions with it, so two deletes cover the messaging side:
curl -sX DELETE "$API/v1/projects/$PROJECT_ID/topics/orders" \
-H "Authorization: Bearer $CAI_TOKEN"
curl -sX DELETE "$API/v1/projects/$PROJECT_ID/topics/orders-dead" \
-H "Authorization: Bearer $CAI_TOKEN"
You should see, for each:
{"name":"orders","status":"deleting"}
Then the function, and the port-forward:
platformctl delete order-worker
kill %1
You should see:
deleted order-worker
What to carry into production
| Rule | What it means for your code |
|---|---|
| Delivery is at-least-once | The same message can arrive twice. Make your handler idempotent — for example, ignore an order_id you've already processed. |
| Subscriptions must exist before you publish | A message published to a topic with no subscription is accepted and then reclaimed. Create the reader first. |
| The ack deadline is also the timeout | If your handler can take 90 seconds, an ack_deadline_seconds of 30 will fail it and retry it forever. Size the deadline to your slowest run, plus a cold start. |
| Always set a dead-letter topic | Otherwise poison messages disappear silently. And subscribe to the dead-letter topic, or its messages are reclaimed too. |
| Push targets are project-local | The platform will only POST to a Service inside your own project's namespace. |
| Topics claim budget, not usage | A topic reserves its max_bytes from the project's 1 GiB the moment it exists. |
Next steps
- Publish and consume — pull, push, acknowledgements, and delivery semantics in full.
- Topics and subscriptions — every field on both objects and what it changes.
- HTTP and events — the complete request/response contract for functions.
- Functions troubleshooting — real error strings and what causes them.
- Pub/Sub API reference — endpoints, defaults, and error messages.