Skip to main content

Triggers

A trigger calls one of your services for you, so nobody has to make the HTTP request by hand. This page tells you exactly what each kind of trigger sends, so you can write a handler that works the first time. It also tells you what the platform does when your handler refuses the delivery.

Triggers work with anything you can deploy: a serverless service, a function, or an agent.

The three kinds of trigger

Source typeFires whenRuns as
scheduleA repeating clock schedule comes roundA scheduled job that posts one event and exits
pubsubA message is published to a topic in your projectA subscription, owned by the trigger, that pushes each message to your service
objectstoreNew objects appear in an S3-compatible bucket (any object store that speaks Amazon S3's API)A poller that lists the bucket on an interval

Two rules that apply to all of them

A trigger can only fire a service in its own project. You give it a target name, and nothing more. No field anywhere accepts another project, not in the API body and not in the object underneath. So calling across projects is not blocked by a check somebody had to remember to write. There is nowhere to express it.

Delivery uses the service's internal address, never its public URL. So a private, unpublished service is still fireable. Trigger traffic stays on the platform's internal network the whole way; it never goes out to the public gateway and back in.

Three ways to do all of this

Every step below is shown three ways — platformctl, curl, and the console. Pick whichever tab suits you and stay in it; your choice follows you across every page in these docs. The CLI commands are platformctl serverless triggers create|update|delete|get|list, and the console keeps them in the Triggers section of its Serverless page.

Triggers live on the serverless API, not the platform API. That is why the curl examples use $CAI_SERVERLESS_API and not $CAI_API. Both are served on the same public hostname — see the API overview:

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

The CLI needs none of that; it resolves the endpoint and your current project itself.

Scheduled triggers

Exactly what arrives at your handler

This is the part you cannot guess, so here it is in full. Each firing sends one request:

POST /your/target/path HTTP/1.1
Content-Type: application/json
ce-specversion: 1.0
ce-id: 3f1a9c22b70d48e1
ce-source: //platform.crusoe.ai/projects/ab12cd/triggers/nightly-rollup
ce-type: ai.crusoe.trigger.schedule
ce-time: 2026-08-12T02:00:00Z
ce-deliveryattempt: 1

{"job":"rollup"}

Point by point:

  • The method is always POST. Never GET, even when there is nothing to send.
  • The body is your payload, character for character, with Content-Type: application/json. Leave the payload blank and the body is {} — a valid, empty JSON object, not an empty body. So a handler that parses JSON never needs a special case for "nothing arrived".
  • The metadata rides in the ce-* headers. Those follow CloudEvents 1.0. A CloudEvent is an industry-standard envelope that labels an event with what happened, where, and when. Putting those labels in headers, instead of wrapping the body in them, is called binary content mode. Your payload is never wrapped, so a handler written before you had ever heard of CloudEvents still works. It just reads the body.
  • ce-type is always ai.crusoe.trigger.schedule. Use it to tell a scheduled firing apart from a Pub/Sub message, whose type is ai.crusoe.pubsub.message.v1.
  • ce-source names the exact trigger. A service fired by several triggers can therefore tell which one called.
  • ce-id is unique per firing and stays the same across retries of that firing. ce-deliveryattempt counts up from 1. Together they let you recognize a repeat and skip work you already did.

The path comes from the trigger's own target.path, tacked onto the end of the service's internal address. It defaults to /. Point a trigger at an agent and you almost certainly want /invoke. An agent's root path is not the path that runs it.

A function handler for the event above. The body is all your handler sees, so reading job is the whole job:

def handle(event: dict) -> dict:
job = event.get("job", "default")
# ... do the work ...
return {"statusCode": 200, "ok": True, "job": job}

Two things to know when the target is a function. Whether the ce-* headers reach your handler depends on the runtime, so check yours before you build on them. A Python function gets every one of them as event["_cloudevent"] — a dict keyed by attribute name with the ce- prefix stripped, so event["_cloudevent"]["source"] names the trigger that fired and ["deliveryattempt"] counts the try. Header matching is case-insensitive, and a payload that already carries its own _cloudevent key keeps it rather than being overwritten. The Node.js, Go and Ruby shims read the body and nothing else, so those handlers see no attributes at all: if one of them has to tell two triggers apart, say so in the payload. And because every firing carries ce-id, the function shim treats it as a CloudEvent: it answers 204 and throws your return value away. The statusCode above is there for the day you call the same handler over plain HTTP. What still matters is whether your handler raises, and the next section is where that lands.

Your status code decides whether it is retried

The platform sorts your response into exactly three buckets:

Your responseWhat happens
Any 2xxSuccess. Done.
429, or any 5xxRetried, up to retry.max_attempts.
Any other 4xx (400, 404, 422, …)Terminal. Never retried.
No response — connection refused, reset, timeoutRetried.
A 400 from your handler kills the delivery permanently

This is the sharpest edge on the page. Return 400 because the payload looked wrong and that firing is gone for good. The platform will not try again, on the reasoning that a malformed request will still be malformed next time. So return 503, not 400, when the failure is only temporary — a database was down, or a dependency timed out. Otherwise you lose the run and nothing tells you.

A function target reaches that state without you writing 400 anywhere. An exception raised inside a CloudEvent delivery is answered 400 by the shim, so a handler that crashes on a temporary problem is not retried either. Catch it and return normally if you want the retry.

Each attempt gets 30 seconds to answer. A handler still working at 30 seconds counts as a failed attempt and is retried. That 30 seconds is the scheduled job's own HTTP client timeout, not a platform-wide ceiling — do not carry the number over to the other two sources, and do not confuse it with the target's request budget, which defaults to 300 seconds.

The terminal case is logged verbatim, so it is searchable in the trigger's run logs:

http 400 (not retryable): {"error": "user_id is required"}

Retries and backoff

Backoff is the wait the platform inserts before each retry. Making that wait grow gives a struggling service room to recover instead of being hammered.

SettingDefaultValues
retry.max_attempts51–20
retry.backoffexponentialexponential, linear, none

max_attempts counts total attempts, not retries after the first. The default of 5 means one try plus up to four more.

Waits before each retry:

BackoffBefore attempt 2345
exponential (default)1 s2 s4 s8 s
linear2 s4 s6 s8 s
none0 s0 s0 s0 s

Every mode caps a single wait at 60 seconds, so a large max_attempts cannot push a retry days into the future.

retry reaches schedule and Pub/Sub triggers only. A scheduled job gets it as its own retry loop, and a Pub/Sub subscription gets max_attempts as its redelivery limit. The bucket poller reads neither: it delivers once. The API accepts a retry block on an objectstore trigger without complaint and stores it, so seeing it come back on the trigger is not evidence that anything acts on it. An object-store delivery that fails is gone — see object-store triggers for what to do about that.

Retries default to on for a concrete reason: on a service that scales to zero, the first attempt often arrives while a fresh instance is still starting. When every attempt fails, the run ends with:

giving up after 5 attempts: http 503: service unavailable

and the job is marked failed, so it shows up as a failure rather than only in logs nobody is tailing.

Execution policy: three behaviors that surprise people

1. A missed window is skipped, not caught up. Sometimes the platform cannot start a firing within 60 seconds of its scheduled time. A machine was down, say, or the platform was busy. That firing is dropped. It is never replayed later, so four hours of backlog will not land on you at once when things recover.

What to do instead: suppose you need a billing run to happen at least once for every unit of work. Do not treat one firing as one unit of work. Have the job look up its own work each time. For example, query for rows added since the last one it processed.

2. Firings never overlap. If a delivery is still running when the next window comes round, the platform does not start the next one. So an hourly trigger whose work takes 70 minutes fires roughly every two hours, not every hour. If your schedule seems to run at half the rate you set, this is why.

3. retry.max_attempts is the only place retries happen. The job underneath is configured never to retry itself, precisely so that max_attempts: 5 means five attempts and not thirty. What you configure is what you get.

Write a cron schedule

A cron expression is the Unix way of writing a repeating schedule. It has five fields, in this order: minute, hour, day-of-month, month, day-of-week. Shorthands starting with @ are also accepted. The API checks your expression when you create the trigger. A typo is therefore a refusal now, rather than a trigger that quietly never fires.

*/15 * * * * every 15 minutes
0 2 * * * 02:00 every day
0 0 * * MON-FRI midnight on weekdays
@daily once a day
@every 90m every 90 minutes

Accepted shorthands are @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly, and @every followed by a Go duration such as 30s, 5m, or 1h30m.

Sunday is 0, and only 0

Most crontab(5) implementations let you write 7 for Sunday. This one does not:

the day of week field has 7, outside the allowed 0-6 - Sunday is 0 here, not 7, unlike crontab(5)

This is the single most common mistake when copying an expression over from a server crontab.

Other refusals you may meet, verbatim:

cron must have exactly 5 fields (minute hour day-of-month month day-of-week), got 6 in "0 0 1 * * *". A 6-field expression with seconds is a different dialect and is not accepted here
"90 minutes" is not a duration @every understands - use a Go duration such as 30s, 5m or 1h30m

The time zone is a standard region name such as America/Chicago. It defaults to UTC. Pick a zone that observes daylight saving time and your schedule shifts twice a year: once it skips an hour, and once it runs an hour twice. UTC never does either.

Create a scheduled trigger

Fire the reporting service at 02:00 every day, with {"job":"rollup"} as the body.

platformctl serverless triggers create nightly-rollup \
--target reporting \
--type schedule \
--cron '0 2 * * *' \
--time-zone UTC \
--payload '{"job":"rollup"}'

You should see:

NAME TYPE SOURCE TARGET STATE LAST RUN
nightly-rollup schedule 0 2 * * * reporting pending -

pending is the normal first answer: the trigger object exists, and its schedule is wired up a moment later. Add --max-attempts and --backoff to override the retry defaults, and --target-path /invoke when the target is an agent.

The target must already exist in your project. A typo is a 404 at create time rather than a trigger that looks fine and never fires. The target list is not limited to serverless services — a function or an agent is a valid target from any of the three doors.

Pub/Sub triggers

A pubsub trigger subscribes to a topic — a named stream of messages — in the same project, and delivers each message to your service.

platformctl serverless triggers create order-ingest \
--target ingest \
--type pubsub \
--topic orders \
--subscription ingest-orders
  • topic is required, and must be a topic in this project. Name one that does not exist and the trigger stays pending, with the reason written on the trigger's own message: no topic named "orders" in this project.
  • subscription is optional. Leave it blank and the subscription takes the trigger's own name. Each subscription keeps its own place in the stream, so two triggers on one topic each read every message instead of splitting them.
  • Suspending does not lose messages. The platform switches the subscription to pull mode rather than deleting it. In pull mode nothing is delivered until someone asks, so messages pile up while the trigger is suspended and drain when you resume.

The rest of the delivery details belong to the Pub/Sub service: the headers, how a message is acknowledged, and where messages go when they cannot be delivered. Those are documented in publish and consume and the Pub/Sub API reference.

Object-store triggers

An objectstore trigger watches a bucket and fires when it finds objects. The bucket can live in any store that speaks Amazon S3's API. The trigger works by polling, which means it lists the bucket every so often and acts on what it sees. Nothing has to be configured on the storage side to notify us.

Polling has two consequences that catch people out. A drop is never instant — at the default poll_seconds of 60, an object can sit in the bucket for a minute before anything happens, which reads exactly like a broken trigger. And suspend does not stop the poller: the trigger will report state: suspended and keep firing. Deleting it is what stops it.

FieldRequiredDefaultWhat it is
bucketYesThe bucket to watch.
endpointYesThe S3 endpoint. No default: the platform does not host the object store and cannot guess where it is.
credentials_secret_nameYesName of a Secret stored in your project that holds the S3 credentials. If the project is connected to Crusoe Cloud, that Secret already exists and is called crusoe-object-store. See the credentials secret.
after_readYesnone — you must choosemove, delete, or none. See below.
prefixNowhole bucketWatch only object names that start with this text — for example incoming/ to ignore the rest of the bucket.
regionNous-east-1Must match the bucket's region. It is handed straight to the poller's S3 client, and a mismatch fails at connect time with nothing on the trigger to say so. See the region must match the bucket.
move_toWhen after_read is movebucket and/or prefix to move consumed objects to.
poll_secondsNo60How often the bucket is listed. It is also the worst-case delay between a drop and a firing.
max_messages_per_pollNo10Cap per poll, so a bucket that gains 10,000 objects at once does not become 10,000 calls at once.
force_path_styleNotrueAddress the bucket as endpoint/bucket rather than bucket.endpoint.
eventsNo["created"]A poll can only see objects that are there, so created is the meaningful value. A poll cannot observe a deletion.

after_read has no default, on purpose

The poller does not remember where it got to. It tracks progress by retiring each object it has handled. Leave objects in place and every poll finds them again and fires again — forever.

ValueBehavior
moveCopy the object to move_to, then remove it from the source. Keeps the data and delivers once.
deleteRemove it once delivered. Destructive, and says so.
noneLeave it — and fire again on every poll, forever. Fine for a target that is idempotent, meaning a repeat call with the same input changes nothing the first call did not already change. A catastrophe for one that sends email.

Every possible default would be wrong for somebody, so there is no default. Leaving the field out is refused:

source.objectstore.after_read must be "move", "delete" or "none" - there is no default, because a bucket trigger that never retires an object fires on it forever

Choosing none is allowed, and the platform keeps reminding you rather than letting you forget. The trigger's No repeat delivery readiness check stays in a waiting state and names the bucket. The decision shows up on the trigger itself, instead of on a bill weeks later.

afterRead: none - every poll re-reads bucket "incoming" and fires this trigger again for objects it has already delivered. Deliberate and legitimate for an idempotent target; use afterRead: move or delete otherwise

Choosing move without a destination is refused too — moving an object onto itself re-delivers it on every poll:

after_read: move needs move_to.bucket or move_to.prefix - moving an object onto itself re-delivers it on every poll

The credentials secret

source.objectstore.credentials_secret_name is required, and the secret must belong to this project

The field names a Secret stored alongside your project's own workloads. It is not a value from the Secrets Manager, and it is not a Crusoe Cloud API key — object storage refuses one of those with 403 The AWS access key Id you provided does not exist in our records.

If your project is connected to Crusoe Cloud, that Secret already exists and is called crusoe-object-store. Connecting the project mints a real S3 key from the connection and stores it in your project, so the name is all you need. In the console you do not even type it: choosing a bucket from the picker fills the bucket, endpoint, region and credentials Secret together.

Two cases where it is not there yet:

  • The project was connected before the platform minted these. Open Project Settings and press Check again on the Crusoe Cloud credential. Only a verdict of valid re-derives the Secret, so a key Crusoe Cloud has just rejected cannot overwrite one that works. The same button repairs a Secret somebody deleted. A key is minted only when the project has none, so pressing the button twice does not leave a second key lying in your Crusoe Cloud account.
  • The project is not connected to Crusoe Cloud at all. Then the Secret is yours to create in your project, with the access key under aws.accessKey and the secret key under aws.secretKey. Those two field names are fixed by the S3 event source the platform polls with rather than chosen by us, and the poller reads exactly those keys — a Secret spelled any other way is read as empty.

Naming a secret instead of pasting one is also what makes the trigger safe. Your credentials never appear inside the trigger object, and there is no field in which another project's secret could be named.

If your bucket lives in Crusoe Cloud, the Crusoe Cloud integration is where you create and list buckets and where the picker's list comes from.

The region must match the bucket

The region is not a preference. It goes verbatim to the poller's S3 client, so a value that disagrees with the bucket fails when that client connects — and it fails quietly. The trigger is created, the readiness checks say nothing about a region, and the bucket is simply never read.

us-east-1 is the default because the poller needs some region, not because it is likely to be yours. A Crusoe Cloud bucket's region looks like us-southcentral1-a.

That is also why the console's bucket picker overwrites the Region box instead of protecting what is in it. The box ships pre-filled with us-east-1, so a rule that filled only an empty field would protect the default and leave us-east-1 in place for a bucket that lives elsewhere.

Create an object-store trigger

Watch incoming/in/, fire thumbnailer for each new object, and move consumed objects under processed/ so they are never delivered twice.

platformctl serverless triggers create image-ingest \
--target thumbnailer \
--type objectstore \
--bucket incoming \
--prefix in/ \
--endpoint https://s3.example.com \
--region '<the bucket region>' \
--credentials-secret s3-ingest-credentials \
--after-read move \
--move-to-prefix processed/

Leave --after-read out and the CLI passes that omission straight through to the API's refusal. That is deliberate: it will not guess for you.

Leave --region out and you get us-east-1, which is only right if that is where the bucket is. There is no picker on the command line to correct it for you.

Suspend instead of deleting

Every trigger has a suspend flag. Set it to true and a schedule or pubsub trigger stops firing but keeps its configuration. That is what you want at 3 a.m. when a trigger is hammering a broken service. Deleting it would throw the configuration away. For a Pub/Sub trigger, deleting would also lose the subscription's place in the stream.

It does not stop an object-store trigger. See the table below before you rely on it.

The console shows you that a trigger is suspended, but it has no control that suspends one. This action is performed using the CLI or REST API.

platformctl serverless triggers update nightly-rollup --suspend=true

You should see:

NAME TYPE SOURCE TARGET STATE LAST RUN
nightly-rollup schedule 0 2 * * * reporting suspended succeeded 2026-08-12T02:00:04Z

The STATE column folds suspension into the state word, because a suspended trigger reading ready would be the most misleading row this table could print. Pass --suspend=false to resume.

What suspending actually stops depends on the source:

SourceWhile suspended
scheduleThe scheduled job is paused rather than deleted, so its run history survives. Windows that pass while suspended are gone; they are not caught up on resume.
pubsubThe subscription switches to pull mode. Nothing is lost and nothing is replayed. Messages keep piling up, though, and they all arrive at once when you resume — so fix the target before resuming.
objectstoreNothing. The poller keeps listing the bucket and keeps firing, while the trigger reports state: suspended. Suspension is wired to the scheduled job and the Pub/Sub subscription only, and the bucket poller is re-converged on every reconcile. Worse, after_read is still in force, so every one of those invisible firings retires an object: with move or delete a bucket you believe is paused is being drained. Delete the trigger to stop it, and recreate it from the same fields to resume. last_outcome is empty for this source whether or not it is firing, so nothing in the status contradicts the word suspended either.

The same PATCH accepts target, source, and retry. Anything you leave out keeps its current value. A block you do send replaces that block whole, so a source in a PATCH must be complete.

Read what happened

platformctl serverless triggers get nightly-rollup -o json

The table view shows only the most recent outcome. -o json is where the fields below actually live, runs included.

What the platform observed sits at the top level of the trigger, next to the settings you sent:

FieldMeaning
stateLowercase, and this resource's own vocabulary: pending until the trigger is wired up, then ready. not_ready when it cannot fire, suspended when you have paused it.
readyA plain true/false: can this trigger fire right now. The field to branch on, spelled the same way on every resource here.
messageOne sentence saying why it is not ready — the missing topic, the target that no longer exists. Left out entirely when it is ready.
delivery_urlThe internal address firings are posted to.
last_fired_atWhen it last fired.
last_outcomesucceeded, failed, running, or pending. Empty for a trigger that has never fired and always empty for object-store triggers, which poll continuously and leave no per-firing record.
consecutive_failuresHow many failures in a row.
conditionsThe named pass/fail checks behind state, each with its own reason and message.
runsA short window of recent firings — not a log.
Run history is a short window, not a log

The platform keeps roughly the last successful run and the last three failed runs for a scheduled trigger, and older ones are deleted. Do not treat runs as an audit trail; log what matters from inside your handler.

runs comes back on the single-trigger GET only. The list route returns last_fired_at, last_outcome, and consecutive_failures for every trigger, but leaves out the per-firing detail. Four runs times fifty triggers is a response nobody reads.

List and delete

platformctl serverless triggers list

You should see:

NAME TYPE SOURCE TARGET STATE LAST RUN
nightly-rollup schedule 0 2 * * * reporting ready succeeded 2026-08-12T02:00:04Z
order-ingest pubsub orders ingest ready -
image-ingest objectstore incoming thumbnailer ready -

LAST RUN is blank for a trigger that has never fired, and always blank for an object-store trigger — its poller runs continuously and leaves no per-firing record.

Delete one:

platformctl serverless triggers delete nightly-rollup

You should see:

deleted nightly-rollup

Deleting removes the machinery behind the trigger too: the scheduled job, the subscription, or the bucket poller. It is the only way to stop an object-store trigger polling.

The reverse is not true. Deleting a service does not delete the triggers pointing at it. They stay, and start reporting that their target cannot be found. The console warns you about this when you delete a service.

Common errors

StatusMessageFix
400name must be 1-52 characters of lowercase letters, digits and dashes, start with a letter and end with a letter or digitFix the trigger name — or the target service name, which follows the same rule and raises the same message.
400source.type must be "schedule", "pubsub" or "objectstore"Check the spelling of source.type.
400source.schedule.cron is required when type is scheduleAdd a cron expression.
400the day of week field has 7, outside the allowed 0-6 - Sunday is 0 here, not 7, unlike crontab(5)Use 0 for Sunday.
400source.pubsub.topic is required when type is pubsubName a topic in this project.
400source.objectstore.bucket is required when type is objectstoreName the bucket.
400source.objectstore.endpoint is required - the platform does not host the object storeGive the S3 endpoint.
400source.objectstore.credentials_secret_name is required, and the secret must belong to this projectName a Secret in your project — crusoe-object-store if the project is connected to Crusoe Cloud, otherwise one you created yourself. See the credentials secret.
400source.objectstore.after_read must be "move", "delete" or "none" - there is no default, because a bucket trigger that never retires an object fires on it foreverChoose one. See after_read has no default.
400after_read: move needs move_to.bucket or move_to.prefix - moving an object onto itself re-delivers it on every pollGive move_to a bucket or prefix that differs from the source.
404not foundThe target service does not exist in this project, or the project is not yours. The platform deliberately does not tell you which.
409a trigger with that name already exists in this projectPick another name, or patch the existing trigger.

Next steps