A surprising number of background jobs boil down to one thing: call this URL every hour. Refresh a cache, sync orders from an API, send a daily digest, ping a webhook. Your app already has the logic behind an endpoint. You just need something to hit it on schedule.
This guide covers three ways to do that, the curl flags that make the difference between a reliable job and a silent one, and how to protect the endpoint so only your scheduler can trigger it.
Option 1: crontab and curl
If you have a Linux server, this is the quickest route. Run crontab -e and add a line:
*/15 * * * * curl -fsS --max-time 30 https://example.com/tasks/sync >> /var/log/sync.log 2>&1
That calls the URL every 15 minutes. The flags matter more than they look:
-
-fmakes curl exit with an error on HTTP 4xx and 5xx responses. Without it, a 500 from your app counts as success. -
-sShides the progress bar but still prints errors. -
--max-time 30stops a hung request from piling up behind the next run. -
>> ... 2>&1keeps a log, so you can see what happened at 3 am.
Sending a POST with JSON and a secret
Most task endpoints should reject anonymous requests. Keep the token in a file readable only by the cron user, not in the crontab itself:
#!/usr/bin/env bash
# /usr/local/bin/call-sync.sh
set -euo pipefail
TOKEN="$(cat /etc/myapp/cron-token)"
curl -fsS --max-time 30 \
-X POST https://example.com/tasks/sync \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"source":"cron"}'
*/15 * * * * /usr/local/bin/call-sync.sh >> /var/log/sync.log 2>&1
Putting the request in a script also avoids cron's % problem. Inside a crontab, % means newline, so a JSON body or a date +%F in the line breaks in confusing ways unless you escape it as \%.
Retrying transient failures
curl can retry on its own:
curl -fsS --max-time 30 --retry 3 --retry-delay 10 https://example.com/tasks/sync
--retry covers timeouts and responses like 429, 502, 503 and 504. Only use it if the endpoint is safe to call twice.
Option 2: a systemd timer
On systemd-based servers, timers are a more structured alternative to cron. You get logs in the journal and the option to catch up on missed runs.
# /etc/systemd/system/sync.service
[Unit]
Description=Call the sync endpoint
[Service]
Type=oneshot
ExecStart=/usr/local/bin/call-sync.sh
# /etc/systemd/system/sync.timer
[Unit]
Description=Run sync every 15 minutes
[Timer]
OnCalendar=*:0/15
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now sync.timer
systemctl list-timers sync.timer
journalctl -u sync.service --since today
Persistent=true runs the job once at boot if the machine was off when it was due.
Option 3: a hosted HTTP scheduler
Both options above need a server that's always on, and both fail quietly when that server goes away. If the job is only an HTTP call, a hosted scheduler removes the server from the picture. You give it a URL and a schedule, and it makes the request.
delay.run works this way: one API call creates a recurring cron (standard five-field syntax, as often as every minute) or a one-off job that runs right away or after a delay of up to 24 hours. Every run is logged with the request, response body, status code and latency, and you can get alerts by email, Slack or Discord when a cron fails several times in a row. It's plain HTTP with no SDK, so it works with any language or framework.
The main thing to compare between hosted options is what you can see after the fact: whether you get the response body of a failed run, and whether anything tells you when a schedule keeps failing.
Secure the endpoint
A task URL that anyone can trigger is an invitation for abuse. Pick one of these:
A shared secret in a header. Simple and good enough for most internal jobs:
// Express
app.post('/tasks/sync', (req, res) => {
if (req.get('authorization') !== `Bearer ${process.env.CRON_TOKEN}`) {
return res.status(401).end();
}
// ... do the work ...
res.json({ ok: true });
});
A signed request. Better when the scheduler supports it. The sender computes an HMAC of the body and a timestamp with a shared key, and your endpoint recomputes it. The timestamp stops someone replaying an old request:
import crypto from 'node:crypto';
function verify(rawBody, timestamp, signature, secret) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // 5 minutes
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
return signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
The exact header names and the string that gets signed depend on the scheduler, so check its docs. Always verify against the raw request body, before JSON parsing changes it.
Design the endpoint for scheduled calls
A few habits make scheduled HTTP jobs much easier to live with:
-
Return a real status code. 200 when the work succeeded, 5xx when it didn't. Schedulers and curl
-frely on it. -
Respond with a short summary.
{"synced": 42}in the response body shows up in logs and makes "did it actually do anything?" easy to answer. - Be idempotent. Retries, overlaps and manual re-runs will happen. Running the same job twice shouldn't send two emails or charge a card twice.
- Keep it short or hand off. If the work takes minutes, accept the request, queue the work, and return quickly. Long requests hit timeouts on proxies and serverless platforms.
Quick checklist
- curl uses
-fand--max-time - secrets live in a file or environment variable, not in the crontab
- the endpoint rejects requests without the secret or a valid signature
- the endpoint returns 5xx on failure
- there's a log of every run, and something alerts you when runs keep failing
Get those right and "call this URL every 15 minutes" becomes a job you can forget about, in the good way.
Top comments (0)