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 type | Fires when | Runs as |
|---|---|---|
schedule | A repeating clock schedule comes round | A scheduled job that posts one event and exits |
pubsub | A message is published to a topic in your project | A subscription, owned by the trigger, that pushes each message to your service |
objectstore | New 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.
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. NeverGET, even when there is nothing to send. - The body is your
payload, character for character, withContent-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-typeis alwaysai.crusoe.trigger.schedule. Use it to tell a scheduled firing apart from a Pub/Sub message, whose type isai.crusoe.pubsub.message.v1.ce-sourcenames the exact trigger. A service fired by several triggers can therefore tell which one called.ce-idis unique per firing and stays the same across retries of that firing.ce-deliveryattemptcounts up from1. 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:
- Python
- Node.js
- Go
- Ruby
def handle(event: dict) -> dict:
job = event.get("job", "default")
# ... do the work ...
return {"statusCode": 200, "ok": True, "job": job}
'use strict';
function handle(event) {
const job = (event && event.job) || 'default';
// ... do the work ...
return { statusCode: 200, ok: true, job };
}
module.exports = { handle };
package main
func Handle(event map[string]any) (map[string]any, error) {
job, _ := event["job"].(string)
if job == "" {
job = "default"
}
// ... do the work ...
return map[string]any{"statusCode": 200, "ok": true, "job": job}, nil
}
def handle(event)
job = (event['job'] if event.is_a?(Hash)) || 'default'
# ... do the work ...
{ 'statusCode' => 200, 'ok' => true, 'job' => job }
end
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 response | What happens |
|---|---|
Any 2xx | Success. Done. |
429, or any 5xx | Retried, up to retry.max_attempts. |
Any other 4xx (400, 404, 422, …) | Terminal. Never retried. |
| No response — connection refused, reset, timeout | Retried. |
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.
| Setting | Default | Values |
|---|---|---|
retry.max_attempts | 5 | 1–20 |
retry.backoff | exponential | exponential, 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:
| Backoff | Before attempt 2 | 3 | 4 | 5 |
|---|---|---|---|---|
exponential (default) | 1 s | 2 s | 4 s | 8 s |
linear | 2 s | 4 s | 6 s | 8 s |
none | 0 s | 0 s | 0 s | 0 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.
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
- curl
- Console
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.
curl -s -X POST "$CAI_SERVERLESS_API/v1/projects/$CAI_PROJECT/triggers" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "nightly-rollup",
"target": {"service": "reporting", "path": "/"},
"source": {"type": "schedule",
"schedule": {"cron": "0 2 * * *", "time_zone": "UTC",
"payload": "{\"job\":\"rollup\"}"}},
"retry": {"max_attempts": 5, "backoff": "exponential"}}'
You should see (HTTP 202 — the trigger is created, then wired up in the background):
{"name": "nightly-rollup",
"target": {"service": "reporting", "path": "/"},
"source": {"type": "schedule", "schedule": {"cron": "0 2 * * *", "time_zone": "UTC", "payload": "{\"job\":\"rollup\"}"}},
"retry": {"max_attempts": 5, "backoff": "exponential"},
"state": "pending",
"ready": false}
state, ready, and everything else the platform observed sit at the top level of the trigger, beside the settings you sent — the same shape a service uses, so one reader handles both.
Note that payload is a string holding JSON, not a nested object — which is why the quotes inside it are escaped.
- Open Serverless and click Add trigger in the Triggers section.
- Set Trigger to Schedule, name it
nightly-rollup, and pickreportingas the Target. - Enter
0 2 * * *as the Cron, leave Time zone atUTC, and put{"job":"rollup"}in Payload. - Click Create trigger.
You should see: a live reading of your cron expression in plain English underneath the field as you type it, and a confirmation naming the schedule once the trigger is created.
The Add trigger button appears only once the project has at least one serverless service, because the API resolves the target at creation and a form that cannot succeed is worse than no form. To put a trigger on a function in a project with no serverless services, use the Add trigger button on that function's own page instead.
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
- curl
- Console
platformctl serverless triggers create order-ingest \
--target ingest \
--type pubsub \
--topic orders \
--subscription ingest-orders
curl -s -X POST "$CAI_SERVERLESS_API/v1/projects/$CAI_PROJECT/triggers" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "order-ingest",
"target": {"service": "ingest", "path": "/"},
"source": {"type": "pubsub", "pubsub": {"topic": "orders", "subscription": "ingest-orders"}}}'
Serverless, then Add trigger in the Triggers section. Set Trigger to Pub/Sub and fill in the Topic and, optionally, the Subscription name.
You should see: a note that the new trigger reads waiting on Pub/Sub until its subscription is ready. That is a waiting state and not a failure — the platform creates the subscription for you, and the trigger turns ready once it is.
topicis required, and must be a topic in this project. Name one that does not exist and the trigger stayspending, with the reason written on the trigger's ownmessage:no topic named "orders" in this project.subscriptionis 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.
| Field | Required | Default | What it is |
|---|---|---|---|
bucket | Yes | — | The bucket to watch. |
endpoint | Yes | — | The S3 endpoint. No default: the platform does not host the object store and cannot guess where it is. |
credentials_secret_name | Yes | — | Name 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_read | Yes | none — you must choose | move, delete, or none. See below. |
prefix | No | whole bucket | Watch only object names that start with this text — for example incoming/ to ignore the rest of the bucket. |
region | No | us-east-1 | Must 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_to | When after_read is move | — | bucket and/or prefix to move consumed objects to. |
poll_seconds | No | 60 | How often the bucket is listed. It is also the worst-case delay between a drop and a firing. |
max_messages_per_poll | No | 10 | Cap per poll, so a bucket that gains 10,000 objects at once does not become 10,000 calls at once. |
force_path_style | No | true | Address the bucket as endpoint/bucket rather than bucket.endpoint. |
events | No | ["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.
| Value | Behavior |
|---|---|
move | Copy the object to move_to, then remove it from the source. Keeps the data and delivers once. |
delete | Remove it once delivered. Destructive, and says so. |
none | Leave 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.accessKeyand the secret key underaws.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
- curl
- Console
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.
curl -s -X POST "$CAI_SERVERLESS_API/v1/projects/$CAI_PROJECT/triggers" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "image-ingest",
"target": {"service": "thumbnailer", "path": "/"},
"source": {"type": "objectstore",
"objectstore": {"bucket": "incoming", "prefix": "in/",
"endpoint": "https://s3.example.com",
"region": "<the bucket region>",
"credentials_secret_name": "s3-ingest-credentials",
"after_read": "move",
"move_to": {"prefix": "processed/"}}}}'
Serverless, then Add trigger in the Triggers section. Set Trigger to Object store. Fill in Bucket, Prefix, S3 endpoint, Credentials Secret, and choose After an object is read. The Move to fields appear only when you choose move.
If the project is connected to Crusoe Cloud, pick the bucket from the dropdown rather than typing its name: that fills S3 endpoint, Region and Credentials Secret in one go. It replaces Region even when the box already holds a value, on purpose — the box ships pre-filled with us-east-1, and a region that disagrees with the bucket is wrong rather than preferred. It fills Credentials Secret only when that box is empty, so a project that keeps its own S3 Secret keeps it.
You should see: the form's own sentence about the choice you made under After an object is read, and — if you chose none — a confirmation that stays on screen rather than fading, naming the bucket it will re-read forever.
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
- curl
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.
curl -s -X PATCH "$CAI_SERVERLESS_API/v1/projects/$CAI_PROJECT/triggers/nightly-rollup" \
-H "Authorization: Bearer $CAI_TOKEN" \
-H "Content-Type: application/json" \
-d '{"suspend": true}'
You should see the trigger come back with the flag set and its state changed:
{"name": "nightly-rollup", ..., "suspend": true, "state": "suspended", "ready": false, ...}
Send {"suspend": false} to resume.
What suspending actually stops depends on the source:
| Source | While suspended |
|---|---|
schedule | The 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. |
pubsub | The 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. |
objectstore | Nothing. 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
- curl
- Console
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.
curl -s "$CAI_SERVERLESS_API/v1/projects/$CAI_PROJECT/triggers/nightly-rollup" \
-H "Authorization: Bearer $CAI_TOKEN" \
| jq '{state, ready, last: .last_outcome, last_fired_at, consecutive_failures}'
In the Triggers section of the Serverless page, open the trigger. Its details carry a Recent firings table and a Readiness checks table — the individual checks the state word is a summary of.
What the platform observed sits at the top level of the trigger, next to the settings you sent:
| Field | Meaning |
|---|---|
state | Lowercase, 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. |
ready | A plain true/false: can this trigger fire right now. The field to branch on, spelled the same way on every resource here. |
message | One sentence saying why it is not ready — the missing topic, the target that no longer exists. Left out entirely when it is ready. |
delivery_url | The internal address firings are posted to. |
last_fired_at | When it last fired. |
last_outcome | succeeded, 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_failures | How many failures in a row. |
conditions | The named pass/fail checks behind state, each with its own reason and message. |
runs | A short window of recent firings — 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
- curl
- Console
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
curl -s "$CAI_SERVERLESS_API/v1/projects/$CAI_PROJECT/triggers" \
-H "Authorization: Bearer $CAI_TOKEN"
You should see:
{"triggers": [{"name": "nightly-rollup", ...}, {"name": "order-ingest", ...}]}
Long lists come back a page at a time. Ask for a page size with page_size (default 50, max 200). Each response carries a next_page_token, a bookmark you send back as page_token to get the following page. That style of paging is called cursor pagination: you never ask for "page 4", you just follow the bookmark forward.
On the last page, next_page_token is left out entirely rather than sent as an empty string. So test whether the key is there, not whether it equals "".
Delete one:
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE \
"$CAI_SERVERLESS_API/v1/projects/$CAI_PROJECT/triggers/nightly-rollup" \
-H "Authorization: Bearer $CAI_TOKEN"
You should see:
204
The Triggers section of the Serverless page lists every trigger in the project, with what fires it, what it targets, and when it last fired. A function's own page carries the same list, narrowed to the triggers aimed at it.
To delete one, open it and click Delete trigger.
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
| Status | Message | Fix |
|---|---|---|
| 400 | name must be 1-52 characters of lowercase letters, digits and dashes, start with a letter and end with a letter or digit | Fix the trigger name — or the target service name, which follows the same rule and raises the same message. |
| 400 | source.type must be "schedule", "pubsub" or "objectstore" | Check the spelling of source.type. |
| 400 | source.schedule.cron is required when type is schedule | Add a cron expression. |
| 400 | the day of week field has 7, outside the allowed 0-6 - Sunday is 0 here, not 7, unlike crontab(5) | Use 0 for Sunday. |
| 400 | source.pubsub.topic is required when type is pubsub | Name a topic in this project. |
| 400 | source.objectstore.bucket is required when type is objectstore | Name the bucket. |
| 400 | source.objectstore.endpoint is required - the platform does not host the object store | Give the S3 endpoint. |
| 400 | source.objectstore.credentials_secret_name is required, and the secret must belong to this project | Name 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. |
| 400 | 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 | Choose one. See after_read has no default. |
| 400 | after_read: move needs move_to.bucket or move_to.prefix - moving an object onto itself re-delivers it on every poll | Give move_to a bucket or prefix that differs from the source. |
| 404 | not found | The target service does not exist in this project, or the project is not yours. The platform deliberately does not tell you which. |
| 409 | a trigger with that name already exists in this project | Pick another name, or patch the existing trigger. |
Next steps
- Serverless overview — what you are firing.
- Functions: HTTP and events — how a function handler receives a request.
- Object-store triggers — the function side of an
objectstoretrigger: what the body actually contains, and why the object's key is not in it. - Pub/Sub: publish and consume — the message side of a
pubsubtrigger. - Crusoe Cloud integration — buckets for an
objectstoretrigger. - Serverless API reference — every trigger field and route.