Cron over REST
Trigger VitNode's cron endpoint from an external scheduler - the URL, the CRON_SECRET bearer token, a curl call, and what the response means.
Every cron run in VitNode is one HTTP request, so the simplest adapter is no
adapter at all: point something at the endpoint once a minute. That something can
be GitHub Actions, a managed scheduler, a Kubernetes CronJob or a systemd
timer. Because it lives outside your app, this is also the only option that works
on serverless hosting, where nothing stays alive long enough to hold a timer.
| Cloud (serverless) | Self-hosted |
|---|---|
| ✅ Supported | ✅ Supported |
Quick start
curl -X POST https://example.com/api/@vitnode/core/cron \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $CRON_SECRET"Cron jobs processed. Executed 2 jobs.Both headers are load-bearing. The Authorization one is the endpoint's only
auth; the Content-Type one gets you past CSRF protection, which treats a
POST with no content type as a form submission and answers 403 Forbidden.
Set it up
Set a real CRON_SECRET
The endpoint compares the bearer token against CRON_SECRET and refuses
anything else with a 403. Leave the variable unset and VitNode falls back to
default-cron-secret-change-in-production - a value published in this
documentation, which makes the endpoint effectively open to anyone who can guess
your domain.
CRON_SECRET=b7c1f0e2d9a84c53b6e1f7a02d48c9e5This is the one secret an external scheduler carries
Use at least 16 random characters, and store it in your scheduler as a secret
rather than in a URL. Do not expect the AdminCP to nag you about it: the
Integrations screen only reports the default secret once a cron adapter is
registered in the API config, and an external scheduler registers none - so
the cron card there says "No cron adapter configured" and stops. On this
deployment shape, remembering the secret is on you.
Point a scheduler at the endpoint
Once a minute, whatever the schedules on your jobs say. The endpoint runs what is
due at the moment it is called, so the tick interval is the finest resolution
any job can have - and core's own process-queue job is * * * * *, so a
slower tick means queue tasks sit around waiting.
Your scheduler needs three things, and this is where providers differ:
| Requirement | Why |
|---|---|
POST | the route declares method: "post" and nothing else |
a custom Authorization header | the bearer token is the whole of the auth |
a custom Content-Type header | application/json, or CSRF protection rejects the request |
A GitHub Actions workflow is the version with no account to create:
name: VitNode cron
on:
schedule:
- cron: '*/5 * * * *'
workflow_dispatch:
jobs:
tick:
runs-on: ubuntu-latest
steps:
- run: |
curl --fail-with-body -X POST "$API_URL/api/@vitnode/core/cron" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $CRON_SECRET"
env:
API_URL: ${{ vars.API_URL }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}Five minutes, not one: GitHub's shortest schedule interval is five minutes, and runs on a busy shared queue are often later than that. It is a fine way to keep daily jobs honest and a poor way to drain a queue.
Verify it from the AdminCP
Send the request by hand once. A 200 with a job count means the endpoint
accepted you; open /admin/core/advanced/cron and the rows you expected to run
now show a Last Run of a moment ago.
Then leave the scheduler to it and check back. Last Run drifting behind a job's schedule is the symptom of a tick that is not arriving, and it is the only signal that reflects an external scheduler - see the gotcha about the Integrations screen below.
Endpoint reference
| Property | Value |
|---|---|
| Method | POST |
| Path | /api/@vitnode/core/cron |
| Auth | Authorization: Bearer $CRON_SECRET - the prefix is stripped before comparing, so a bare secret matches too |
| Body | none |
| Calling it twice | safe: the second call runs only what has become due since the first |
| Response | Body | Means |
|---|---|---|
200 | Cron jobs processed. Executed N jobs. | the tick completed; N counts the jobs whose handler did not throw |
403 | Forbidden | CSRF: a POST whose content type is missing, text/plain or a form type, with no trusted Origin |
403 | Invalid cron authorization | the bearer token does not match CRON_SECRET |
500 | Error processing cron jobs | the bookkeeping itself failed - usually the database |
Gotchas
Vercel Cron Jobs cannot call this endpoint
Vercel triggers a cron path with a GET; this route only answers POST, so a
vercel.json entry pointed straight at it never runs a job. Use a scheduler
that can send a POST with headers - the Vercel deployment
guide says the same thing from the other
side.
A 200 does not mean every job succeeded
A handler that throws is logged and skipped - the tick still answers 200,
and the failed job is simply missing from the count. Worse, its lastRun is
not written, so it is due again on the next tick. Read the failures in the
Debug Panel; the response only tells you the
endpoint was reached.
Ticking from two places doubles the ticks
An external scheduler and the Node CRON adapter are alternatives, not layers. Configure both and two callers race for the same due jobs, which is the situation idempotent handlers exist for.
The Integrations screen still calls cron inactive
/admin/core/system/integrations reports cron as active when the API config
registers a cron adapter - it is reading the config, not counting ticks.
Tick from outside and that card stays grey however healthy the schedule is,
and the queue card with it, because the queue's status is derived from cron's.
The honest signal for an external scheduler is the Last Run column on
/admin/core/advanced/cron.
The endpoint is public in the sense that it answers
There is no allowlist and no signature - a correct bearer token is the whole gate, and the route is mounted on your public API alongside everything else. If your platform can restrict the path to your scheduler's egress addresses, that is a second lock worth having.
Next
Declare a cron job
buildCron, where jobs are registered, and the operational rules that come with a scheduler.
Node CRON adapter
The in-process alternative for a long-lived server: one package, no external scheduler.
Queue tasks
One-off background work with retries, drained by the process-queue job on every tick.
Self-hosted deployment
Where to put the secret, and what else a long-running VitNode process needs.