Daily and weekly reminders in local time: one cron job per timezone

How Infrai's per-job IANA timezone handles US and EU daylight shifts, the three-week window where the offset between them is wrong, and the fan-out that sends each reminder once.

A reminder at 08:00 local time is two problems wearing one hat: choosing the right instant, and choosing it again after the clocks move. Infrai’s cron API takes an IANA timezone per job, so 0 8 * * * pinned to America/New_York keeps firing at 08:00 through both the March and the November shift. What the create call won’t do is tell you when you’ve typed the zone name wrong.

That second half is where reminder backends quietly rot.

Two cadences, and only one of them can land on a transition

Daylight transitions happen on Sundays in both the US and the EU. A weekly reminder scheduled for 30 9 * * 1 — Monday 09:30 — therefore never lands on a transition day at all, in any zone that follows the US or EU rules. It’s the boring case, and boring is what you want.

Daily jobs are the ones to look at. A 30 2 * * * job in America/New_York asks for a local time that simply doesn’t exist on 8 March 2026, because the clock jumps from 01:59:59 to 03:00:00. Six months later, on 1 November, 01:30 local happens twice. Schedule your daily sends between 04:00 and 23:00 local and neither edge can reach you; put a reminder at 02:30 and you’ve built a bug into the schedule that fires twice a year, both times on a Sunday morning when nobody’s looking.

The three weeks when New York and Berlin aren’t six hours apart

Teams that hardcode “Europe is six hours ahead of the East Coast” get away with it for about eleven months a year. The US moves on the second Sunday in March and the first Sunday in November; the EU moves on the last Sunday in March and the last Sunday in October. Here’s 2026:

Date (2026)What changedAmerica/New_YorkEurope/BerlinGap
7 MarUTC-5UTC+16h
8 MarUS springs forwardUTC-4UTC+15h
29 MarEU springs forwardUTC-4UTC+26h
25 OctEU falls backUTC-4UTC+15h
1 NovUS falls backUTC-5UTC+16h

Twenty-one days in spring and seven in autumn, the offset is off by an hour. If your scheduler stores UTC minutes computed once at signup, every US and EU user is an hour early or late for four weeks of the year — and support tickets from that window read like flakiness rather than a date bug.

Pinning the zone to the job removes the arithmetic entirely.

Creating one job per zone

POST /v1/cron/create takes cron_expr, a task URL to fire, and timezone. The request field is task; the response calls the same value task_url, which trips people writing the create body from a response they captured earlier.

curl -X POST https://api.infrai.cc/v1/cron/create \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "reminders-daily-nyc",
    "cron_expr": "0 8 * * *",
    "task": "https://hooks.example.com/reminders/sweep",
    "timezone": "America/New_York",
    "overlap_policy": "skip",
    "payload": {"zone": "America/New_York", "cadence": "daily"}
  }'

The job comes back active, and with one field that surprises everybody:

{
  "ok": true,
  "data": {
    "job_id": "cron_fN9yLK1eyKMOkI7rYCJVAMZl",
    "name": "reminders-daily-nyc",
    "cron_expr": "0 8 * * *",
    "task_type": "http_url",
    "task_url": "https://hooks.example.com/reminders/sweep",
    "timezone": "America/New_York",
    "overlap_policy": "skip",
    "enabled": true,
    "status": "active",
    "next_run_at": null,
    "last_run_at": null
  }
}

next_run_at is null on recurring jobs — it’s populated for one-shot jobs created with run_at, not for cron_expr schedules. Don’t treat the null as “the schedule didn’t take”. Use GET /v1/cron/runs/list/{id} after the first expected fire instead.

Two things create accepts that it probably shouldn’t

Here’s the trap that costs an afternoon. The error registry publishes a CRON_TIMEZONE_INVALID code, but in our testing the create route never raised it:

curl -X POST https://api.infrai.cc/v1/cron/create \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "reminders-daily-brussels",
    "cron_expr": "0 8 * * *",
    "task": "https://hooks.example.com/reminders/sweep",
    "timezone": "Europe/Bruxelles"
  }'

That returns HTTP 200 with "timezone": "Europe/Bruxelles" stored verbatim — the French spelling of a zone whose IANA name is Europe/Brussels. The job exists, it’s active, and it will not do what the name suggests. Validate zone strings against Intl.supportedValuesOf('timeZone') before you send them.

The second one is blunter: a malformed cron_expr such as 0 8 * * (four fields instead of five) comes back as a bare HTTP 500, not a 400 with CRON_EXPR_INVALID. Both are limitations to code around rather than trust.

Provisioning is a loop, so validate inside it:

const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY (your_infrai_api_key)");

const ZONES = ["America/New_York", "America/Los_Angeles", "Europe/Berlin", "Europe/London"];
const known = new Set(Intl.supportedValuesOf("timeZone"));

async function createDailyJob(zone) {
  if (!known.has(zone)) throw new Error(`not an IANA zone: ${zone}`);
  const res = await fetch(`${BASE}/v1/cron/create`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify({
      name: `reminders-daily-${zone.replace(/\W+/g, "-").toLowerCase()}`,
      cron_expr: "0 8 * * *",
      task: "https://hooks.example.com/reminders/sweep",
      timezone: zone,
      overlap_policy: "skip",
      payload: { zone, cadence: "daily" },
    }),
  });
  const json = await res.json();
  if (!res.ok || json.ok !== true) throw new Error(`${zone}: HTTP ${res.status} ${JSON.stringify(json.error ?? json)}`);
  return json.data.job_id;
}

for (const zone of ZONES) {
  console.log(zone, await createDailyJob(zone));
}

Four zones, four jobs, four job_ids you can pause independently with POST /v1/cron/pause/{id} when a region needs to go quiet.

The sweep hands work to a queue, not to a loop

The cron task should return in milliseconds. Everything slower belongs behind POST /v1/queue/publish, which is the same key and the same base URL — no second vendor, no second invoice.

curl -X POST https://api.infrai.cc/v1/queue/create \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"tz-reminders-eu-us","type":"standard","dead_letter_queue":"tz-reminders-eu-us-dlq","visibility_timeout_default":120}'

The handler the cron fires reads its own payload, looks up who’s due in that zone, and publishes one message each. delay_seconds buys sub-hour precision without a second schedule: an 08:00 job can spread a cohort across the next 20 minutes, and the ceiling is exactly 604800 seconds (7 days).

import { createServer } from "node:http";

const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const QUEUE = "tz-reminders-eu-us";

async function publishReminder(user, zone, delaySeconds) {
  const res = await fetch(`${BASE}/v1/queue/publish`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify({
      queue: QUEUE,
      payload: { user_id: user.id, kind: "daily_digest", tz: zone },
      delay_seconds: delaySeconds,
      idempotency_key: `rem_${user.id}_${new Date().toISOString().slice(0, 10)}`,
    }),
  });
  if (!res.ok) throw new Error(`publish failed: HTTP ${res.status}`);
  return (await res.json()).data.message_id;
}

async function dueUsers(zone) {
  // Replace with your own query; the zone arrives in the cron payload.
  return zone.startsWith("Europe/") ? [{ id: "u_777" }] : [{ id: "u_401" }];
}

createServer((req, res) => {
  let raw = "";
  req.on("data", (c) => { raw += c; });
  req.on("end", async () => {
    try {
      const { zone } = JSON.parse(raw || "{}");
      const users = await dueUsers(zone ?? "UTC");
      await Promise.all(users.map((u, i) => publishReminder(u, zone ?? "UTC", i * 5)));
      res.writeHead(202).end(JSON.stringify({ enqueued: users.length }));
    } catch (err) {
      res.writeHead(500).end(JSON.stringify({ error: String(err.message ?? err) }));
    }
  });
}).listen(8080);

The worker pulls, sends, and acknowledges by message_id:

const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const QUEUE = "tz-reminders-eu-us";

async function call(path, body) {
  const res = await fetch(`${BASE}${path}`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`${path} → HTTP ${res.status}`);
  return (await res.json()).data;
}

const { items } = await call("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
for (const msg of items) {
  console.log("sending", msg.payload.user_id, "in", msg.payload.tz);
  const { acked } = await call("/v1/queue/ack", { queue: QUEUE, message_id: msg.message_id });
  if (!acked) console.warn("ack returned false for", msg.message_id);
}

Check acked. An ack for an id the broker doesn’t recognise returns HTTP 200 with "acked": false, so status-code-only error handling reports success on a message that’s still in flight and will be redelivered.

Confirming a schedule is really firing

curl -s https://api.infrai.cc/v1/queue/stats/tz-reminders-eu-us \
  -H "Authorization: Bearer $INFRAI_API_KEY"

delayed_count is the field to watch when you use delay_seconds. The publish response echoes "status": "available" even for a message that won’t be visible for hours, so the stats endpoint is the honest one.

What the schedule layer costs

Every cron route — create, list, runs, pause, resume, trigger — is free and rate-limited. Publishing is the billable step, at $0.00002 per message, verified 2026-07-26, and new accounts get $2 of free credit before anything is charged. A 50,000-user daily reminder run is therefore about $1 a month in enqueue fees; the delivery channel you choose downstream will cost more than the queue does.

Rates move down and discount campaigns run, so read today’s number rather than this paragraph:

curl -s "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer $INFRAI_API_KEY"

Where another tool is the better pick

If your workers already run beside a Redis you operate, BullMQ’s job schedulers give you repeatable jobs with the same timezone semantics plus in-process concurrency control, and you’ll pay nothing per message. Its trade-off is that Redis becomes a durability problem you own. QStash is the closest hosted comparison for HTTP-fired schedules and is a fine choice if scheduling is genuinely all you need — the argument for Infrai isn’t that it schedules better, it’s that the reminder you just scheduled also has to be sent, stored and billed to a tenant, and those live on the same key. If you need per-second precision or workflow-level retries with compensation, stick with something built for it.

References

Browse more queue developer guides