Cron jobs and workflows
Five-field crontabs that POST to your own endpoints on the minute — plus durable workflows that sleep and wait for events, queues and a dead-letter replay.
A cron job is a timer that calls your app. You give it a schedule and a path; every minute the scheduler checks which jobs are due and POSTs the ones that are. No worker process, no extra service to keep alive.
When many jobs are due together, Camplax keeps draining them in batches. A backlog does not expire after an hour. A job that fell behind gets one catch-up call, then returns to its next future scheduled time; it does not replay every missed interval. Queued calls stay scheduled until execution starts.
The schedule
Expressions are a classic five-field crontab, evaluated in UTC:
┌───────────── minute 0–59
│ ┌───────────── hour 0–23
│ │ ┌───────────── day 1–31
│ │ │ ┌───────────── month 1–12
│ │ │ │ ┌───────────── weekday 0–6 (0 is Sunday)
* * * * *
Each field takes *, a number, a list (1,15), a range (9-17), or a step (*/5, 9-17/2). 0 9 * * 1 is 09:00 UTC every Monday; */15 * * * * is every fifteen minutes.
Unsure what an expression does? POST /v1/projects/<slug>/cron/preview with { "expression": "…" } returns the next five fire times — the console's planner calls the same route as you type.
What your endpoint receives
A job's endpoint is a path on your own app — /jobs/digest, not a URL. Absolute URLs are rejected, so a job can only ever call https://<slug>.camplax.app:
POST https://<slug>.camplax.app/jobs/digest
User-Agent: Camplax-Cron/1.0
Camplax-Cron-Job: crn_…
Camplax-Cron-Run: run_…
Camplax-Timestamp: 1790000000
Camplax-Key: fb51a6ab…
Camplax-Signature: v1=DQptM5…
Content-Type: application/json
{ "jobId": "…", "runId": "…", "name": "Daily digest", "expression": "0 9 * * *", "firedAt": "…" }
A scheduled run gives your endpoint 30 seconds before it is marked failed; a 2xx is a success and anything else is a failure with the HTTP status recorded. A redirect is a failure too — the signed call never follows one, so the signature cannot be replayed somewhere else.
Check that a call came from Camplax
Your cron path is a public URL — anyone can POST to it. Every cron, one-off and workflow call is signed by Camplax, so your app can refuse the rest. There is no secret on your side: Camplax signs with its own private key and your app checks the signature with Camplax's public key. Nothing to create, copy, paste or rotate.
In a Plax app, check the raw body before you parse it:
import { readRawBody, sendFail, sendOk, verifyCronRequest } from "@camplax/plax";
"POST /jobs/digest": async (req, res) => {
const raw = await readRawBody(req);
const verified = await verifyCronRequest(req, raw);
if (!verified.ok) return sendFail(res, verified); // 401
// verified.data.runId — skip the work if you already did this run
// (workflow steps share a run: key those on runId + verified.data.stepId)
sendOk(res, { ok: true });
},
verifyCronRequest accepts the call only when the signature checks out and Camplax-Timestamp is within 5 minutes of your clock. The signature covers your project's id, so a call Camplax made to another project never passes in yours. runId and stepId come from the signed body, not from headers, so a replayed request cannot pose as a new run. Plax fetches Camplax's public key once and caches it.
Outside Plax, the check is: fetch https://camplax.dev/v1/signing/keys (public, no credential; /v1/cron/keys serves the same keys), take the key whose kid matches Camplax-Key, and verify the Ed25519 signature in Camplax-Signature (base64url after v1=) over ` ${timestamp}.${projectId}.${rawBody} , where projectId is your app's CAMPLAX_PROJECT_ID`. The same key signs log drains and alert webhooks over a different message, so neither can pass as a cron call.
Every project gets signed calls, old ones included — nothing to deploy or turn on first.
Managing jobs
In the console's Cron page you can create, edit, pause and delete jobs. The same thing over the API, with your console session:
| Method | Path | What it does |
|---|---|---|
GET | /v1/projects/<slug>/cron/jobs | List jobs with last and next run times |
POST | /v1/projects/<slug>/cron/jobs | Create — { name, expression, endpoint, enabled? } |
PATCH | /v1/projects/<slug>/cron/jobs/:id | Edit, or pause with { "enabled": false } |
DELETE | /v1/projects/<slug>/cron/jobs/:id | Delete |
POST | /v1/projects/<slug>/cron/preview | Next five fire times for an expression |
GET | /v1/projects/<slug>/cron/runs | Run history — ?status=ok|fail|running, up to 200 rows |
GET | /v1/projects/<slug>/cron/job-limit | How many jobs this project can hold, and your latest request for more |
POST | /v1/projects/<slug>/cron/job-limit-requests | Request more — { reason, requestedLimit? } |
Every run is recorded with its status, duration and error, and the runs list rolls up today's counts and median duration. A failed cron run is not retried — the schedule fires it again next time. For retries you want a workflow.
Limits
Jobs: a project can hold 100 jobs on every plan. Creating the 101st is refused with 409 (cron_job_limit), and the answer offers Request more: tell Camplax what the jobs are for, and support reviews it and emails you. An approval raises that project's limit at once. Jobs declared in camplax.json count too. A deploy creates them up to the limit and lists the rest as failed in the project's activity, and the deploy itself goes ahead. After a raise, the next deploy creates the rest.
Runs: your account's projects share a monthly number of runs — 100,000 on Indie, 300,000 on Pro and 500,000 on Scale. The Usage page's Cron runs meter shows where you are. Every kind of run counts: a scheduled fire, Run now, a one-off and a workflow run.
- At 80% of your runs, the account owner gets one email that month.
- Past your plan's runs, more are paid from your account's prepaid credits: 50¢ per 10,000 runs, taken 10,000 at a time before they are used. The owner gets one email the first time that happens in a month.
- With no credits left, runs are skipped. Each one is marked failed with an error that says to add credits, and your app is not called. Add credits on the Billing page and the next run goes ahead; otherwise runs start again on the 1st (UTC). Starting a workflow run answers
429instead. The owner gets one email when runs stop.
Nothing is billed after the fact: runs past your plan only ever come out of credits you bought first.
Workflows
A workflow is an ordered list of steps, each one an endpoint on your app with its own reliability settings:
- Attempts — up to 10 tries per step.
- Backoff — none, fixed 2s, exponential 2s→32s, or a longer exponential 5s→5m.
- Timeout — per step, 5s to 5m.
- Dead-letter — a step that exhausts its attempts can park on the dead-letter queue instead of vanishing.
Create one with POST /v1/projects/<slug>/cron/workflows, start a run with POST /cron/workflows/:id/run (optionally { "payload": { … } }), tune a step later with PATCH /cron/workflows/steps/:id, and delete one with DELETE /cron/workflows/:id (or its ⋯ menu in the console) — runs still in flight stop as canceled, and past runs stay in the history. Workflow runs appear in the same runs list — and those can be retried with POST /cron/runs/:id/retry.
Every step call carries the run's payload (what it started with) and waits (what each wait step received) in the signed body.
Sleep and wait
A step does not have to call your app. It can pause the run:
| Step | Settings | What happens |
|---|---|---|
{ "kind": "sleep", "sleepMs": 86400000 } | 1 second to about a year | The run sleeps, then moves on |
{ "kind": "sleep", "until": "$.remindAt" } | an ISO time, or a $.field of the payload | Sleeps until that time; a time already past is no sleep |
{ "kind": "wait", "event": "order.approved", "match": "orderId", "waitTimeoutMs": 604800000, "onTimeout": "continue" } | event name, optional match field, timeout (default 7 days), continue or fail | The run waits until your app sends that event |
A paused run costs nothing — it is a row and an alarm, and no code runs until it is due. A wait with match: "orderId" only resumes on an event whose data.orderId equals the run payload's orderId (42 and "42" match). If the timeout passes first, the next step sees waits[<stepId>] = { "timedOut": true } — or the run fails, with "onTimeout": "fail". Only events sent after the run reached the wait count.
Your app sends events with its own injected token — nothing to set up:
import { createPlaxEvents } from "@camplax/plax";
const events = createPlaxEvents();
await events.send("order.approved", { orderId: order.id }, { id: `approve-${order.id}` });
That is POST /v1/projects/<slug>/events with { name, data?, id? } and the app's CAMPLAX_PROJECT_TOKEN (scope events:send; camplax.events.send() in the SDK). id makes a retried send a no-op (a send that died midway is finished by the retry); the same id with a different name or data is refused with 409. A project can send 3,600 events an hour and have 10,000 workflow runs in flight (running or waiting); past either the answer is 429. The reply says how many runs it resumed and which runs it started. Preview deployments cannot send events — preview code never resumes production's runs.
Event triggers. A workflow created with "trigger": "event:user.created" starts a run on every user.created event, with the event's data as the run's payload. GET /cron/events lists the events your app sent in the last 30 days.
Agent approvals. Pass events: createPlaxEvents() to a Plax agent() with approval-gated tools, and it sends agent.approval_requested when it pauses and agent.approval_resolved ({ threadId, approved, denied }) when someone answers. A workflow triggered by the first can wait on the second — match: "threadId", a day's timeout — instead of polling the thread. Tool arguments are never sent.
Queues
The Queues tab shows named queues with waiting, in-flight and per-minute throughput. Messages that exhaust their retries land on the dead-letter queue; once you have fixed the cause, POST /v1/projects/<slug>/cron/queues/dead-letter/replay moves every waiting dead-letter message back onto a live queue. GET /cron/queues and GET /cron/queues/:id/messages let you inspect what is parked.
Runs also show up in the project's log stream, next to deploy output and runtime lines.
Under the hood: a Durable Object ticks every minute, due jobs go through a Cloudflare Queue, and every run lands in the cron_runs table. A paused workflow run holds a per-run Durable Object alarm; the minute tick re-sends any wake that is more than a minute late.