Skip to main content

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.

The one confirmed wiring, honestly

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.
  • platformctl built, 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.
  • kubectl access 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"}'
Topics spend your budget the moment they exist

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:

FieldWhy this value
ack_deadline_seconds: 60On 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-binaryBinary 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 3Fail three times, then move the message to orders-dead instead of retrying forever.
Push targets must live in your own project

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, not 500. For a plain HTTP call, a crash is a 500.
  • 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.

No dead-letter topic means the message is lost

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

RuleWhat it means for your code
Delivery is at-least-onceThe same message can arrive twice. Make your handler idempotent — for example, ignore an order_id you've already processed.
Subscriptions must exist before you publishA message published to a topic with no subscription is accepted and then reclaimed. Create the reader first.
The ack deadline is also the timeoutIf 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 topicOtherwise poison messages disappear silently. And subscribe to the dead-letter topic, or its messages are reclaimed too.
Push targets are project-localThe platform will only POST to a Service inside your own project's namespace.
Topics claim budget, not usageA topic reserves its max_bytes from the project's 1 GiB the moment it exists.

Next steps