Running the app
Cron setup
Question creation and resolution are driven by external HTTP cron jobs, not by timers inside the app. This is a once-per-deployment setup task.
Why external cron
The backend runs no setInterval and no in-process scheduler. Everything time-driven is
an idempotent HTTP endpoint that something else calls.
That choice buys two things: the container can sleep between requests without stopping the schedule, and every scheduled action becomes a URL you can hit yourself when debugging. The cost is one more service to configure — this page.
cron-job.org is free and sufficient. Any scheduler that can send an authenticated POST works equally well.
The five jobs
All five use method POST and the header:
Authorization: Bearer <your ADMIN_TOKEN>
Use the production ADMIN_TOKEN from your Railway environment variables, not the dev
default.
Leave the request body empty. None of these endpoints take parameters. The backend
accepts an empty body under any Content-Type — including application/json, which
Fastify would otherwise reject outright — so you do not have to make the scheduler send
{} to keep it happy.
| Job | URL path | Schedule (UTC) | Meaning |
|---|---|---|---|
| SMI — create | /admin/smi/daily |
0 7 * * 1-5 |
08:00 CET, weekdays |
| SMI — resolve | /admin/smi/resolve |
30 16 * * 1-5 |
17:30 UTC = 18:30 CET, weekdays |
| YouTube — tick | /admin/youtube/resolve |
*/15 * * * * |
Every 15 minutes, all week |
| Notify — new questions | /admin/notifications/dispatch |
*/15 * * * * |
Every 15 minutes, all week |
| Predictor — tick | /admin/predictor/tick |
*/5 * * * * |
Every 5 minutes — paused until the season window has closed, see below |
Prefix each path with your backend's public URL, e.g.
https://your-backend.up.railway.app/admin/smi/daily.
Note: The YouTube job drives both ends of the race — it snapshots baselines at 12:00 and resolves at 24:00. It is not "the resolve job" despite the URL. Running it every five minutes keeps the real measured window close to the nominal twelve hours.
Warning: Keep the predictor tick disabled until the season window has closed. The first tick freezes the expert pool and the pool is never recomputed, so a tick while registrations are still open locks in a smaller cohort and silently drops everyone who joins later. Enable the job once, after the last question of the window has resolved. The other four jobs run normally throughout — participants never see the predictor.
Note: The notification job is what emails participants that a question is open. Without it, questions still publish on time but nobody is told. It is a separate job because it has to fire after the 08:00 publish, not when the question was created.
YouTube question creation stays manual on purpose; see Admin console → The YouTube panel.
Creating the jobs, step by step
cron-job.org accounts are free and personal — the jobs hold nothing but a URL and a token, so a successor can recreate them in ten minutes on their own account. See Handover.
- Sign up at cron-job.org and confirm the address. Use one you will still read next semester; it is where failure alerts go.
- Console → CREATE CRONJOB.
- Title — name it for what it does, not for the URL:
SMI create,SMI resolve,YouTube tick,Notify. In six months you will be reading this list while something is broken. - URL — the backend's public URL plus the path, e.g.
https://your-backend.up.railway.app/admin/smi/daily. It must behttps. - Execution schedule — pick the times from the table above. The editor is a grid of
minutes/hours/days rather than a cron string; for the two five-minute jobs there is a
ready-made Every 5 minutes option. Set the time zone to
Europe/Zurich— see below. - Advanced — this section is collapsed by default and contains the two settings that
actually matter:
- Request method:
POST. The default isGET, and every one of these endpoints will reject aGET. - Headers: add
Authorizationwith valueBearer <your ADMIN_TOKEN>. One header, name and value in separate fields — do not paste the whole line into the name box.
- Request method:
- Notifications — switch on notify on failure and notify on disable. Without them a broken job is invisible until a student asks why there was no question.
- Save, and make sure the job is enabled.
Repeat for all five. Then verify — see below.
Limits worth knowing
The free tier is generous but has three edges that produce confusing symptoms:
| Limit | Free tier | What it looks like when you hit it |
|---|---|---|
| Request timeout | 30 s | Job marked failed even though the endpoint did its work |
| Response size | 64 KB | Execution aborted — "output too large" |
| Consecutive failures | 25 | The job is disabled automatically |
The timeout is the realistic one. A Railway container that has scaled to zero needs a few seconds to wake, and the SMI job then calls an external quote provider. If a job intermittently reports a timeout but the question appears anyway, that is what happened — the work committed, the response just arrived late.
Danger: The auto-disable after 25 failures is the trap. Rotate
ADMIN_TOKENwithout updating the header and every job returns403; the five-minute jobs burn through 25 failures in about two hours and switch themselves off. Re-enabling is a manual click, and the only symptom is that nothing happens the next morning.
The daylight saving trap
Switzerland uses CET (UTC+1) in winter and CEST (UTC+2) from late March to late
October. Cron schedules are in UTC and do not follow that. The app's internal anchors
do — schedule.ts probes the Zurich offset for the specific date — so only the
external cron times drift.
The SMI jobs are the ones affected:
| Winter (CET) | Summer (CEST) | |
|---|---|---|
| SMI — create | 0 7 * * 1-5 |
0 6 * * 1-5 |
| SMI — resolve | 30 16 * * 1-5 |
30 15 * * 1-5 |
The YouTube and notification ticks run every five minutes, so they are timezone-agnostic.
On cron-job.org, set the job's time zone to Europe/Zurich and the problem disappears.
Each job carries its own time zone (it defaults to UTC), and the service applies the
offset for you. Schedule the SMI jobs at 08:00 and 17:30 Zurich local and leave
them alone — they follow the country twice a year without you touching anything.
08:00 and 17:30 are both far from the 02:00–03:00 window where DST transitions cause a local time to be skipped or repeated, so there is no edge case to reason about here.
If your scheduler has no time zone setting
Run both UTC variants year-round: four SMI jobs instead of two, the winter pair and the summer pair, all enabled. Every endpoint is idempotent, so the call that fires at the "wrong" hour does nothing:
/admin/smi/dailyrefuses to create a second question for the same day, and refuses entirely once voting has closed./admin/smi/resolveonly touches questions that are still unresolved, and waits until after 18:00 Zurich before attempting today's.
Duplicate calls are harmless. Twice-yearly manual edits are not, because they get forgotten.
Verifying the jobs work
Do this immediately after setup rather than discovering a problem a week in.
1. Call each endpoint by hand. All four return 200 with a log array describing
what they did:
curl -X POST https://your-backend.up.railway.app/admin/smi/daily \
-H "Authorization: Bearer $ADMIN_TOKEN"
{ "ok": true, "log": ["[smi] created question 42 for 2026-07-28 (prev close 12043.51 on 2026-07-27)"] }
An empty log usually means "nothing to do", which is a valid outcome — the question
already existed, or it is the weekend.
2. Check cron-job.org's execution history the next morning. It records the status
code of every run. A run of 403 means the token is wrong; 404 means the URL is wrong.
3. Check the dashboard at /admin/questions — today's SMI question should be there.
What each endpoint does
POST /admin/smi/daily
Skips weekends. Skips if a question already exists for today. Skips if today's 12:00 voting deadline has already passed. Otherwise fetches recent SMI closes, takes the most recent one before today as the reference, and creates the question published at 08:00 with a 12:00 deadline.
POST /admin/smi/resolve
Finds unresolved SMI questions. For today's question it waits until after 18:00 Zurich. Compares the day's close to the stored previous close: strictly higher resolves A, otherwise B. If a past question still has no close data it assumes a public holiday and deletes the question and its votes.
POST /admin/youtube/resolve
Two idempotent halves. First, any question whose race_starts_at has passed and has no
baseline yet gets its two view counts snapshotted. Second, any question whose
race_ends_at has passed and has a baseline gets closed: fetch current views, compare
the deltas, resolve to the larger. Exact ties are skipped and retried next tick.
Both halves need YOUTUBE_API_KEY; without it the endpoint returns 503.
POST /admin/notifications/dispatch
Finds questions that are visible (published_at <= now) and still open
(deadline > now) and have not been announced yet, then emails every participant who has
email notifications switched on — one message per recipient, so no participant sees
another's address. It records questions.notified_at afterwards, which is what makes
repeat calls harmless.
Two deliberate silences:
- A question published more than 24 hours ago is marked as processed and not emailed. This is what stops a first deployment, a restored backup, or a multi-day cron outage from blasting stale announcements.
- With no
RESEND_API_KEYset, the intended recipients are written to thelogarray instead of being emailed. Useful locally; a silent failure in production.
{
"ok": true,
"notified": [{ "question_id": 42, "title": "SMI: Higher close today?", "recipients": 31 }],
"log": ["[notify] question 42 announced to 31 recipient(s)"]
}
If a send fails, notified_at stays NULL and the next tick retries that question.
Cost note
The Railway backend config currently carries a comment telling you not to enable scale-to-zero because of in-process timers. Those timers no longer exist — everything moved to the cron endpoints described here. Scale-to-zero is compatible with this design; the first cron call of the morning simply wakes the container.
If you enable it, allow for a cold start: the SMI job may take a few extra seconds, which is harmless given the hour of slack before the 08:00 publish.
Next
→ Deployment: the services these jobs point at.