Scheduled work

Cron schedules declared in the manifest — five-field UTC expressions, the X-Revenexx-Schedule header, per-tenant ticks, and the warmup switch.

A cron schedule is how your app runs without an HTTP caller. Declare it; the platform's scheduler invokes your function.

manifest.json
"schedules": [
  { "name": "expire-warranties", "cron": "0 3 * * *" },
  { "name": "nightly-sync", "cron": "*/15 * * * *", "data": { "mode": "delta" } }
]
FieldRequiredDescription
nameYesIdentifier, unique within the app. Sent to the app as X-Revenexx-Schedule on every tick.
cronYesStandard 5-field cron expression, evaluated in UTC.
functionNoThe entrypoint to invoke. Defaults to the app's primary function.
dataNoStatic JSON payload delivered as the execution body on every tick.

Five fields, and UTC

text
┌───────── minute        (0–59)
│ ┌─────── hour          (0–23)
│ │ ┌───── day of month  (1–31)
│ │ │ ┌─── month         (1–12)
│ │ │ │ ┌─ day of week   (0–6, Sunday = 0)
│ │ │ │ │
0 3 * * *      every day at 03:00 UTC
*/15 * * * *   every fifteen minutes
0 2 * * 1      Mondays at 02:00 UTC

Five fields. No seconds field, no year field.

UTC is not negotiable, and it is the detail that causes the support ticket. 0 3 * * * is 03:00 UTC — which is 04:00 in Berlin in winter and 05:00 in summer. A merchant who asked for "3 a.m. before the shop opens" gets a different local hour twice a year.

So: if the exact local hour matters to the business, do not encode it in the cron. Run the job more often than you need, read the tenant's own configuration inside the handler, and act only when it is the right local time for that tenant. If the hour only has to be "quiet", pick a UTC hour and stop thinking about it.

Branch on the header

A tick carries no path — only the schedule name — so branch on the header before your router sees the request:

src/main.js
module.exports = async (context) => {
  const headers = context.req.headers || {};
  const schedule = headers['x-revenexx-schedule'] ?? '';

  if (schedule === 'expire-warranties') {
    return context.res.json(await expireWarranties(context));
  }
  if (schedule === 'nightly-sync') {
    return context.res.json(await syncFromErp(context, context.req.body));
  }
  if (schedule) {
    return context.res.json({ schedule, ignored: true });
  }

  return app.handler()(context);
};

Handle an unknown schedule name explicitly. A schedule you removed from the manifest but whose handler branch you deleted first should log "ignored", not fall through to your router and answer a 404 to the platform.

data arrives as the execution body, so context.req.body carries { "mode": "delta" } for the second schedule above.

Ticks are per installed tenant

Schedules run per installed tenant, with the tenant context supplied by the platform. You do not enumerate tenants, and you cannot: one tick is one tenant, and the data client is already scoped to it.

Two consequences worth designing for:

A tick is as expensive as your slowest tenant times the number of tenants. A nightly job that walks every row is fine for one customer and a problem at fifty. Page the work, and prefer a filter that finds the rows that actually need attention over a full scan.

A schedule cannot do cross-tenant work, because there is no cross-tenant context to do it in. That is the same guarantee tenant isolation gives your routes, applied here too.

Writing a job that survives

Make it idempotent. Assume a tick may run twice, and that a run may be interrupted halfway. A job that moves rows from one state to another by filtering on the state it is moving them out of is naturally safe to re-run; a job that appends without checking is not.

Bound the work. Process a page, record where you got to, and let the next tick continue. A job that tries to finish everything in one invocation is the job that times out on the tenant with the most data — and always at 3 a.m.

Log what happened, and return it. The return value lands in the execution record you read in App Studio. { scanned: 4210, expired: 17 } is worth having; {} is not.

Do not put an unbounded external call in a tick without a timeout. See Outbound HTTP.

Reconcile, do not just react. A nightly job that catches what an event handler may have missed is the cheapest insurance available against undocumented delivery semantics.

warmup

manifest.json
"warmup": true

Keeps the app permanently warm via the platform scheduler, so requests never pay a cold start. Default false.

Use it for an app on a latency-sensitive path — a capability the storefront or the checkout awaits. It is not free, so do not switch it on by default: an admin-only app that an operator opens twice a day does not need it, and a cold start on a background job costs nobody anything.

Next steps

Was this page helpful?