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 gets delivered to your function automatically. Failed deliveries are retried, and a message that keeps failing is parked in a dead-letter topic instead of being lost.

You'll build a tiny order-processing pipeline: a topic called orders, a 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

Three words to know before you start:

  • A topic is a named channel you publish messages to.
  • A subscription is a durable reader of that topic. It remembers its own place, so two subscriptions never steal each other's messages.
  • A push subscription is one where the platform POSTs each message to an HTTP endpoint inside your project. A deployed function is exactly such an endpoint.
Two doors onto one mechanism

Functions answer plain HTTP calls and CloudEvent deliveries. A CloudEvent is the standard envelope of headers and fields that the platform wraps an event in. Functions have no trigger settings of their own, so something outside the function has to call it.

There are two ways to arrange that, and underneath they are the same machine. A Pub/Sub trigger is the short version: name a topic and a target, and the platform creates and manages the push subscription for you. This page builds that push subscription yourself, which is the long version — more typing, every knob in your hands. A trigger's subscription is always shared, always starts from new, always uses a 30-second acknowledgement deadline, and cannot be given a dead-letter topic at all. Dead-lettering is the second half of this tutorial, so the long version is the one that fits.

Before you begin

  • A platform account and a project — see Create an account.

  • The admin role on the project. Creating topics and subscriptions is admin-only; publishing, pulling, and deploying a function need only member.

  • Your project connected to Crusoe Cloud. Everything you deploy is built into a container image, and that image is stored in a repository in your own Crusoe Cloud Registry — so a project with no Crusoe Cloud credential is refused before anything is built. Connecting is a one-time, project-admin step, and a project that is already connected needs nothing new. See connect your Crusoe Cloud account.

  • platformctl tab: the CLI built, signed in, and pointed at your project — see Install the CLI.

  • curl tab: the API address, a token, and your project id in your shell:

    export CAI_API=https://api.codyhill.dev
    export CAI_TOKEN="<your session token or API key>"
    export CAI_PROJECT="<your project id>"

    Pub/Sub is served on that same public API, so one address covers both halves of this tutorial. Your project id is a UUID — copy it out of the console URL, or run platformctl projects list and read the ID column. If your install serves Pub/Sub somewhere else, CAI_PUBSUB_API overrides CAI_API for the Pub/Sub calls only.

  • Console tab: nothing else. A browser is enough — with one exception, called out in step 1.

  • jq, for pulling one field out of a JSON response.

Every step below is shown three ways — platformctl, curl, and the console. Pick whichever tab suits you and stay in it; the tabs are the same work through three different doors. Your choice follows you across every page in these docs.

CAI_TOKEN does double duty. curl sends it as a bearer token, meaning one HTTP header whose value is the credential. 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 thrown away — more on that in step 7.

Pick a language. Every step after this one is identical whichever you chose, because the event-delivery contract is the same in all four.

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

Use a plain def, never async def — see runtimes for why an async Python handler fails silently, and why it fails even more silently on an event delivery.

Now deploy it.

platformctl functions deploy ./order-worker --name order-worker

You should see:

packaging ./order-worker...
uploading order-worker (0.3 KiB, framework=function, runtime=python)...
build 7c41d9e2-... accepted
state: -> building
state: building -> deploying
state: deploying -> ready
order-worker is ready at https://order-worker-ab12cd.apps.codyhill.dev

The runtime= on the upload line is whichever language you wrote — the CLI works it out from the handler file in your folder: handler.js means Node.js, handler.go means Go, handler.rb means Ruby, anything else means Python. Force it with --runtime python|nodejs|go|ruby.

The address a push subscription needs

The address in the deploy output is the function's public one. A push subscription needs its internal one instead: push delivery happens inside the platform, and the platform refuses to POST your messages at anything on the public internet.

platformctl status order-worker -o json | jq -r .url

You should see:

http://<private-hostname>

That address is reachable only from inside the platform, which is exactly what a push subscription needs. Keep it to hand:

export FN_URL=http://<private-hostname> # your internal URL from above

2. Create the two topics

orders carries the work. orders-dead is the dead-letter topic: it catches messages that fail over and over, so they are parked instead of lost.

Two rules about dead-letter topics. It 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 — one that fails every single time it is delivered — would be republished straight back into the queue it just poisoned.

platformctl pubsub topics create orders --max-bytes 16Mi --discard old
platformctl pubsub topics create orders-dead --max-bytes 16Mi --discard old

You should see, for each (trimmed — the table prints every field, sorted):

address persistent://p-ab12cd/main/orders
discard old
max_bytes 16Mi
name orders
path projects/ab12cd/topics/orders
ready false
state pending

The full table also carries the topic's counters, which live together under stats and print as stats.published, stats.delivered, stats.backlog_bytes, and their siblings. They are all zero on a topic this new.

pending is normal. The topic object exists immediately, and the message broker catches up a few seconds later. state is the topic's own word for where it is in that catch-up; ready is the boolean to wait on, and it means the same thing here as it does on an agent, an index, or a function.

Topics spend your budget the moment they exist

Each project has a 1 GiB Pub/Sub storage budget. A topic claims its whole max_bytes as soon as it is created, even while empty. So two 16 Mi topics have already spent 32 Mi. Does a create fail with a message about the storage budget? Delete a topic, or lower another topic's max_bytes. Consuming messages never frees budget. platformctl pubsub quota shows what is left.

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: a signal from the reader saying it has finished with that message. Publish to a topic nobody is subscribed to, and the message is accepted, given an id, and then quietly reclaimed.

platformctl pubsub subscriptions create dead-watch --topic orders-dead \
--type shared --ack-deadline-seconds 30

You should see (trimmed):

ack_deadline_seconds 30
deliver.mode pull
max_deliver 5
name dead-watch
ready false
start_from all
state pending
topic orders-dead
type shared

A subscription's counters live under stats too — stats.backlog, stats.unacknowledged, stats.delivered, stats.consumers — and stats.consumers is the first thing to read when a push target is getting nothing.

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.

platformctl pubsub subscriptions create to-worker --topic orders \
--type shared \
--ack-deadline-seconds 60 \
--max-deliver 3 \
--push-url "$FN_URL" \
--push-content-mode cloudevents-binary \
--dead-letter-topic orders-dead \
--dead-letter-after-attempts 3

--push-url selects push delivery on its own, so there is no --delivery push to remember.

You should see (trimmed):

ack_deadline_seconds 60
dead_letter.after_attempts 3
dead_letter.topic orders-dead
deliver.mode push
deliver.push.content_mode cloudevents-binary
deliver.push.url http://<private-hostname>
max_deliver 3
name to-worker
topic orders
type shared

Three choices there 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. So your handler's event is your JSON payload, already parsed, with nothing to unwrap. Structured mode would instead 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

The push URL is strictly checked. It must be http://, no redirects are followed, and the target must be a workload in your own project. Do not assemble the address by hand — copy the url field from the target workload (platformctl status <workload> prints it, and the console's Overview panel shows it as Endpoint) and add your path. That string is exactly what this check accepts.

A public URL, or a service belonging to another project, 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 payload is JSON, so label it as JSON. Leave the label off and 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 written as ledger:entry — two numbers naming where the broker stored the message — and it is your proof the message was stored. 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: they survive scale-to-zero, and your function has probably gone back to sleep. A live tail needs a running instance.

platformctl logs order-worker --history

You should see a line like:

2026-08-12T18:04:11Z stdout processing order A-1001 for 42

Plain platformctl logs order-worker prints the newest running instance's buffered logs once, and -f/--follow tails new lines as they arrive. Both need an instance to be running — an idle function has scaled to zero, which is why --history is the one to reach for here.

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, and this is true in all four runtimes. Three rules change for event deliveries:

  • The response is always an empty 204, and your return value is thrown away. That 204 is the acknowledgement the subscription is waiting for. So event handlers work by their side effects: writing to a store, calling another service, logging.
  • A handler failure answers 400, not 500. For a plain HTTP call, a crash is a 500. A 400 is not retried forever, which is what stops a deterministic bug becoming a retry storm.
  • Everything else is unchanged: the same handler function, the same 8 MiB body cap.

A push delivery counts as delivered only on a 2xx response, meaning any HTTP status from 200 to 299. Anything else is a failed attempt: an error status, or a response slower than the ack deadline. After a failure the platform waits and retries, waiting longer each time. That is backoff: 1 second, then 2, then 4, up to a ceiling of 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

Deploy the broken version and publish another order:

platformctl functions deploy ./order-worker --name order-worker

platformctl pubsub topics publish orders \
--message '{"order_id":"A-1002","total":99}' \
--attribute datacontenttype=application/json

Here is the sequence. The handler fails. The shim, which is the small wrapper the platform runs around your handler, answers 400. The subscription retries after 1 second, then after 2. 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 the message table on stdout and the acknowledgement on stderr:

ACK_ID ID KEY DATA
CIcJEAAYACAA 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-. They record where it came from, how many attempts it used, and why the last one failed. The curl tab's response shows them in full; on the CLI use -o json.

No dead-letter topic means the message is lost

Does a push subscription have no dead-letter topic configured? Then a message that uses up its attempts is simply dropped. The only record is a line in the platform's own server log, which you cannot read. So configure a dead-letter topic for anything you cannot afford to lose — and put a subscription on that topic too, or the dead letters vanish as well.

Now put the working handler back: rewrite the file exactly as you wrote it in step 1, and deploy it again the same way.

9. Clean up

Deleting a topic deletes its subscriptions with it, so two deletes cover the messaging side. Order matters: orders goes first. A topic that some other topic's subscription uses as its dead-letter target refuses to be deleted while that subscription still exists, because deleting it would stop that subscription's failed messages going anywhere. The refusal is a 409 naming the culprit:

topic orders-dead is the dead-letter target for to-worker. Point those subscriptions
elsewhere first - deleting it would stop their failed messages going anywhere.

Deleting orders takes to-worker with it, which clears the way. The cascade is not instantaneous, so if the second delete still reports that 409, wait a few seconds and run it again.

platformctl pubsub topics delete orders
platformctl pubsub topics delete orders-dead
platformctl delete order-worker

You should see:

deleting topic orders and its subscriptions
deleting topic orders-dead and its subscriptions
deleted order-worker

What to carry into production

RuleWhat it means for your code
Delivery is at-least-onceEvery message arrives, but the same message can arrive twice. Make your handler idempotent — safe to run again with the same end result. For example, ignore an order_id you have 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 timeoutSay 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.
Topics claim budget, not usageA topic reserves its max_bytes from the project's 1 GiB the moment it exists.

Next steps

Go deeper

These advanced guides pick up where the quickstarts stop, each exercising a different slice of the platform:

GuideFramework / language
Multi-step research agentLangGraph
Editorial pipeline with a crewCrewAI
Support agent over your own docsADK
Document ingestion pipelinePython
Webhook fan-out, exactly onceNode.js
Scheduled reconciliation jobGo
Object-store ETL with move-after-readRuby