On this page
How it works
A schedule is a cron expression, a time zone and a job template. At each matching minute Halyard creates a job from the template, and from there it behaves like any other job: a worker runs it, failures retry and every run is logged. You don't need a server that stays awake at night, and you can see whether last night's report ran without logging into a box.
The one design decision that matters: the task computes its date from the slot, not from the clock. A run that retries at 02:40, or a backfill you start next Tuesday, still produces the report for the right day.
Build it
Write the report task
job.scheduledForis the slot the run belongs to, set by the schedule. A manual run can pass an explicitdatein the payload instead, which is what the backfill step uses.// reports/worker.jsimport { Worker } from "@halyard/node";import { db } from "./db.js";import { storage } from "./storage.js";import { sendEmail } from "./mailer.js";import { toCsv, previousDay } from "./util.js"; const worker = new Worker(process.env.HALYARD_KEY, { queues: ["reports"] }); worker.task("build-daily-report", async (job) => { // The day before the slot this run belongs to, in the schedule's time zone. const day = job.payload.date ?? previousDay(job.scheduledFor, "Europe/Berlin"); const rows = await db.query( "select sku, count(*) as orders, sum(total) as revenue from orders where paid_on = $1 group by sku order by revenue desc", [day], ); const key = `reports/daily/${day}.csv`; await storage.put(key, toCsv(rows), "text/csv"); await sendEmail({ to: "sales-team@lumen.example", template: "daily-report", data: { day, url: await storage.signedUrl(key, { expiresIn: "7d" }) }, }); return { day, rows: rows.length };}); worker.start();# reports/worker.pyimport osfrom halyard import Workerfrom reports.db import dbfrom reports.storage import storagefrom reports.mailer import send_emailfrom reports.util import to_csv, previous_day worker = Worker(os.environ["HALYARD_KEY"], queues=["reports"]) @worker.task("build-daily-report")def build_daily_report(job): # The day before the slot this run belongs to, in the schedule's time zone. day = job.payload.get("date") or previous_day(job.scheduled_for, "Europe/Berlin") rows = db.query( "select sku, count(*) as orders, sum(total) as revenue from orders where paid_on = %s group by sku order by revenue desc", [day], ) key = f"reports/daily/{day}.csv" storage.put(key, to_csv(rows), "text/csv") send_email( to="sales-team@lumen.example", template="daily-report", data={"day": day, "url": storage.signed_url(key, expires_in="7d")}, ) return {"day": day, "rows": len(rows)} worker.start()Create the schedule
0 2 * * *means 02:00 every day. Withtimezoneset, that is 02:00 in Berlin all year: 00:00 UTC in summer and 01:00 UTC in winter.curl https://api.halyard.example/v3/schedules \ -H "Authorization: Bearer hy_test_51Hq8Zx2rTmV9cKpL3d" \ -H "Content-Type: application/json" \ -d '{ "cron": "0 2 * * *", "timezone": "Europe/Berlin", "queue": "reports", "task": "build-daily-report", "overlap": "skip" }'const schedule = await halyard.schedules.create({ cron: "0 2 * * *", timezone: "Europe/Berlin", queue: "reports", task: "build-daily-report", overlap: "skip",}); console.log(schedule.id, schedule.nextRunAt);// sch_8Vb3Nm1Rx6 2026-10-18T00:00:00Zschedule = halyard.schedules.create( cron="0 2 * * *", timezone="Europe/Berlin", queue="reports", task="build-daily-report", overlap="skip",) print(schedule.id, schedule.next_run_at)# sch_8Vb3Nm1Rx6 2026-10-18T00:00:00ZRun it now
Nobody wants to wait until 2 a.m. to find a typo. Trigger the schedule by hand; the job gets the most recent slot as its
scheduledFor.Terminal$ halyard schedules trigger sch_8Vb3Nm1Rx6Queued job_7Ys4Kd0Pe5 (build-daily-report) for slot 2026-10-17 02:00 Europe/Berlin $ halyard jobs logs job_7Ys4Kd0Pe5 --follow14:22:07 running attempt 1/514:22:09 succeeded { "day": "2026-10-16", "rows": 214 }Backfill missed days
Queue one job per date with an explicit
date. The idempotency key means running the script twice won't build a report twice, and because each report overwritesreports/daily/<date>.csv, rebuilding a day is always safe.// scripts/backfill.jsconst days = ["2026-10-09", "2026-10-10", "2026-10-11"]; for (const date of days) { await halyard.jobs.create({ queue: "reports", task: "build-daily-report", payload: { date }, idempotencyKey: `daily-report:${date}`, });}# scripts/backfill.pydays = ["2026-10-09", "2026-10-10", "2026-10-11"] for date in days: halyard.jobs.create( queue="reports", task="build-daily-report", payload={"date": date}, idempotency_key=f"daily-report:{date}", )
Time zones and DST
Without a timezone, schedules run in UTC. With one, Halyard follows the zone's daylight saving rules:
- A local time that doesn't exist (02:30 on the night clocks spring forward) runs at the next valid minute, 03:00.
- A local time that happens twice (02:30 on the night clocks fall back) runs once, at the first occurrence.
Slow runs and overlap
If one night's report takes longer than a day (a broken index will do that), you don't want the next one starting on top of it. overlap: "skip" skips a slot while the previous run is still queued or running and records the skip in the schedule's history. Use "queue" to run late slots one after another instead, or "allow" for work that is safe to run in parallel.
Recap
- Schedules use a cron expression plus an IANA time zone, so DST is handled for you.
- The task derives its date from job.scheduledFor, which makes reruns and backfills deterministic.
- overlap: "skip" prevents a slow run from stacking up behind itself.
- halyard schedules trigger runs the report now without waiting for 02:00.
Next steps
Keep going with a related guide, or look up every option used here in the Jobs API reference.