Cron Jobs
Run code on a schedule. The platform calls a path of your app on a cron schedule;
your app handles it in its normal handler. Typical uses: send reminder emails,
clean up stale rows, refresh a cache, send a daily digest.
There is no separate "cron handler" — a cron run is just an HTTP request to your app
that carries an auth token and a marker header. Verify it with isCronRequest(req).
Defining jobs: onvibe.json
Jobs are declared, never created imperatively. Add an onvibe.json file at the
project root; it is reconciled on every deploy: jobs are created/updated, and jobs
removed from the file are deleted.
{
"crons": [
{
"name": "reminders",
"schedule": "0 9 * * *",
"path": "/cron/reminders",
"timezone": "Europe/Madrid"
}
]
}
name: unique per project, lowercase letters/digits/hyphens.schedule: standard 5-field cron (min hour day-of-month month day-of-week).path: a path of your app to call, must start with/.method:POST(default) orGET.timezone: IANA name (e.g.Europe/Madrid); defaults toUTC.
Changing jobs: download → edit → deploy
onvibe.json holds the complete set of jobs, so never write it from memory:
get_config({ project_id }) // the live onvibe.json + its `version` lock
// edit the `crons` array, KEEP `version` as-is
deploy({ project_id, files: [{ path: "onvibe.json", content: edited }] })
The deploy is rejected (CONFIG_OUT_OF_DATE) if the config moved since you downloaded
it — call get_config again, re-apply your edit and redeploy. See
onvibe://docs/config.
Two runtime tools complete the picture (they don't define jobs):
run_cron({ project_id, name }) // trigger once now, for testing
get_project({ project_id }) // schedule, next_run and last_run of each job
Handling the trigger in your app
Gate the cron logic with isCronRequest(req) so public traffic can't trigger it.
import { isCronRequest } from "./.onvibe/helpers.ts";
export default async function handler(req: Request): Promise<Response> {
const url = new URL(req.url);
if (url.pathname === "/cron/reminders" && isCronRequest(req)) {
await sendReminders(); // scan DB, send emails, etc.
return new Response("ok");
}
// ...rest of the app
}
isCronRequest(req) returns true only when the request carries the X-Onvibe-Cron
header AND the correct APP_TOKEN bearer (set by the platform). cronName(req)
returns the job name if you serve several jobs from one path.
Limits & semantics
- Granularity: 1 minute (standard cron). No sub-minute schedules.
- Per-run timeout: ~60s. Keep jobs short; for heavy work, do it in batches.
- Overlap: if a run is still going when the next is due, the new one is skipped.
- Missed runs: after downtime, a job fires once (missed slots are not replayed), then resumes its normal schedule.
- At-least-once: design handlers to be idempotent (a run may rarely repeat).
Example: daily expiry reminders
{ "crons": [{ "name": "reminders", "schedule": "0 9 * * *", "path": "/cron/reminders", "timezone": "Europe/Madrid" }] }
import { isCronRequest } from "./.onvibe/helpers.ts";
async function handler(req: Request): Promise<Response> {
const url = new URL(req.url);
if (url.pathname === "/cron/reminders" && isCronRequest(req)) {
const { rows } = await pool.query(
"SELECT email, name FROM items WHERE expires_on = CURRENT_DATE + 3",
);
for (const r of rows) {
// send an email to r.email (see onvibe://docs/email when available)
}
return new Response(`sent ${rows.length}`);
}
return new Response("not found", { status: 404 });
}
export default handler;
Notes
- Test a job without waiting for its schedule with
run_cron(or just hit the path yourself — but it will return early unlessisCronRequestpasses). - To delete a job, remove it from the
cronsarray and deploy — the deploy makes the live jobs match the file exactly. - See each job's schedule, next run and last run with
get_project.