Sent to the notification address in your settings.
- 1Set and confirm the address under Settings, if it differs from your login.
- 2Pick Email as a channel on the job or monitor.
Setup recipes
Step-by-step guides with code you can copy: schedule requests to your app, watch the scripts and sites you run, and send alerts to the place your team already reads.
Which way does the call go?
That decides which recipe you need
These are setup guides, not account connections. CronEngine never signs in to Laravel, WordPress, GitHub, Vercel, Slack or any other service. Each recipe uses a normal cron job, heartbeat, uptime monitor or alert channel, with an address and, where needed, a credential you create for it.
We call your URL on a schedule.
Your script pings us when it finishes.
Tell your team, then your customers.
Keep integration credentials separate. CronEngine encrypts authentication secrets and custom header values before storing them, under a key held outside the database. Use a unique, limited token for each integration and revoke it if the job or URL is exposed. A heartbeat’s ping address works the same way: anyone who has it can ping, so keep it out of public code.
Schedule requests
For work your app can do when asked. You give CronEngine an address, and it calls that address at the times you choose and records what came back.
Laravel normally runs schedule:run from a server cron every minute. CronEngine cannot execute Artisan directly, so your application needs a small HTTPS route that checks a unique token before calling it.
CronEngine job settings
schedule:run is called. A complete Laravel Scheduler bridge therefore needs every-minute execution, which currently fits Business and Enterprise plans. For lower plans, expose and schedule one specific queued task instead. Sub-minute Laravel schedules are not a good fit for an HTTP bridge.
Already running the scheduler on your own server? Then you do not need this route. Watch it with a heartbeat instead.
Laravel scheduler documentation ↗use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Route;
Route::post('/cronengine/schedule', function (Request $request) {
$expected = (string) config('services.cronengine.secret');
$provided = (string) $request->bearerToken();
abort_if(
$expected === '' || ! hash_equals($expected, $provided),
403
);
$exitCode = Artisan::call('schedule:run');
return response()->json(
['ok' => $exitCode === 0],
$exitCode === 0 ? 200 : 500
);
});
// config/services.php
'cronengine' => [
'secret' => env('CRONENGINE_SECRET'),
],
// .env
CRONENGINE_SECRET=generate-a-long-unique-token
Disable WordPress’s page-load trigger only after the external CronEngine job is ready. CronEngine then requests wp-cron.php on a predictable interval.
While you are at it, add an uptime monitor for the site itself, so you also hear about it when the site goes down.
Open the complete WordPress guideQuick recipe
1. wp-config.php
define( 'DISABLE_WP_CRON', true );
Place this before the “stop editing” comment, after confirming the external job works.
2. CronEngine job
Choose an interval that matches how much delay your scheduled posts and plugin tasks can tolerate. If a firewall blocks wp-cron.php, allow CronEngine’s documented outbound IP.
Add the workflow_dispatch event to the workflow, then call GitHub’s workflow-dispatch endpoint with a token that has Actions write access to that repository.
CronEngine job settings
Why scheduled workflows run late, how to create the token and what each error means: read the full guide.
GitHub workflow API documentation ↗name: CronEngine task
on:
workflow_dispatch:
jobs:
scheduled-task:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./scripts/nightly-task.sh
Accept: application/vnd.github+json
X-GitHub-Api-Version: 2026-03-10
{
"ref": "main"
}
Replace WORKFLOW_FILE with the workflow file name, such as cronengine.yml. The ref must name a branch or tag that contains the workflow.
For application work, expose a route that checks a bearer secret. For a scheduled rebuild, create a Vercel Deploy Hook and let CronEngine send a POST request to its generated URL.
Protected function
Set CRON_SECRET in Vercel, use the same value as CronEngine’s Bearer token, and call your production route with GET.
Deploy Hook
Create it under Project Settings → Git → Deploy Hooks. Use POST with no body. The unique hook URL can trigger deployments, so treat the URL itself as a secret.
On the Hobby plan Vercel cron runs at most once a day. The limits per plan and the ways around them: read the full guide.
export function GET(request) {
const authorization = request.headers.get('authorization');
if (
!process.env.CRON_SECRET ||
authorization !== `Bearer ${process.env.CRON_SECRET}`
) {
return new Response('Unauthorized', { status: 401 });
}
// Queue or perform your scheduled work here.
return Response.json({ success: true });
}
Function job
https://your-app.vercel.app/
GET · Bearer token · no body
Deploy Hook job
https://api.vercel.com/
POST · no authentication field · no body
CronEngine supports GET, POST, PUT, PATCH, DELETE, and HEAD. Add custom headers, a JSON or text body where supported, Basic or Bearer authentication, and success rules that match the endpoint. Already have a working curl command? Paste it into the job form and it fills itself in.
Example request
POST{
"source": "cronengine",
"task": "refresh-search-index"
}
Watch your scripts
For work that runs where we cannot reach: a crontab on your server, a queue worker, a CI job. Add one line at the end, and if the ping does not arrive on time, you get an alert. How cron monitoring works.
Every heartbeat gets its own private address. Request it with any method, from any tool that can make a web request. It answers in plain text, so curl -fsS stays quiet when it works.
https://cronengine.com/ping/hb_…
Finished
The run is done and went well. This is the one that resets the clock.
https://cronengine.com/ping/hb_…/start
Started
The run began. Optional, but it lets us measure how long each run takes.
https://cronengine.com/ping/hb_…/fail
Failed
The run broke. You get an alert straight away, without waiting for the grace period.
The shortest version is one line: run the task, and only if it succeeds, ping. A failed task sends nothing, the ping goes missing, and you hear about it once the grace period is over.
CronEngine heartbeat settings
0 3 * * *-m 10 stops a slow network from hanging the script, --retry 3 survives a short blip, and -o /dev/null keeps cron from mailing you the reply.
# Ping when the backup finishes. The && means a failed backup sends nothing,
# so the ping goes missing and you get an alert.
0 3 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 --retry 3 -o /dev/null https://cronengine.com/ping/hb_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
#!/bin/sh
PING=https://cronengine.com/ping/hb_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
curl -fsS -m 10 --retry 3 -o /dev/null "$PING/start"
if /usr/local/bin/backup.sh; then
curl -fsS -m 10 --retry 3 -o /dev/null "$PING"
else
curl -fsS -m 10 --retry 3 -o /dev/null "$PING/fail"
fi
If your server already runs schedule:run, Laravel can ping for you. Give each important command its own heartbeat, so you know exactly which one stopped.
This also catches the scheduler itself going quiet, for example after a server move where nobody set the crontab up again.
Laravel pinging URLs documentation ↗// routes/console.php
use Illuminate\Support\Facades\Schedule;
$ping = config('services.cronengine.backup_ping');
Schedule::command('backup:run')
->dailyAt('03:00')
->pingBefore("{$ping}/start")
->pingOnSuccess($ping)
->pingOnFailure("{$ping}/fail");
// config/services.php
'cronengine' => [
'backup_ping' => env('CRONENGINE_BACKUP_PING'),
],
// .env
CRONENGINE_BACKUP_PING=https://cronengine.com/ping/hb_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
GitHub’s own scheduled workflows can start late or be skipped when GitHub is busy, and a failed run is easy to miss in a long list. A heartbeat tells you either way.
Store the ping address as a repository secret named CRONENGINE_PING, so it never ends up in your code.
The CronEngine Heartbeat action does the pinging for you, masks the address in the logs and never fails your workflow if we cannot be reached. Plain curl works just as well.
Prefer CronEngine to start the workflow on time instead? Use the workflow dispatch recipe above.
name: Nightly export
on:
schedule:
- cron: '0 3 * * *'
jobs:
export:
runs-on: ubuntu-latest
steps:
- uses: cronengine-com/heartbeat-action@v1
with:
ping-url: ${{ secrets.CRONENGINE_PING }}
status: start
- uses: actions/checkout@v4
- run: ./scripts/export.sh
- if: always()
uses: cronengine-com/heartbeat-action@v1
with:
ping-url: ${{ secrets.CRONENGINE_PING }}
status: ${{ job.status }}
name: Nightly export
on:
schedule:
- cron: '0 3 * * *'
jobs:
export:
runs-on: ubuntu-latest
env:
PING: ${{ secrets.CRONENGINE_PING }}
steps:
- uses: actions/checkout@v4
- run: curl -fsS -m 10 --retry 3 -o /dev/null "$PING/start"
- run: ./scripts/export.sh
- if: success()
run: curl -fsS -m 10 --retry 3 -o /dev/null "$PING"
- if: failure()
run: curl -fsS -m 10 --retry 3 -o /dev/null "$PING/fail"
The same pattern in each: ping at the start, ping when it works, ping /fail when it breaks. Each one swallows its own network errors, so a ping that cannot get out never stops the actual job.
import urllib.request
PING = "https://cronengine.com/ping/hb_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
def ping(suffix=""):
try:
urllib.request.urlopen(PING + suffix, timeout=10)
except OSError:
pass # never let the ping break the job itself
ping("/start")
try:
run_import()
ping()
except Exception:
ping("/fail")
raise
// worker.mjs (Node.js 18 or newer)
const PING = 'https://cronengine.com/ping/hb_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
const ping = (suffix = '') =>
fetch(PING + suffix, { signal: AbortSignal.timeout(10_000) })
.catch(() => {}); // never let the ping break the job itself
await ping('/start');
try {
await runImport();
await ping();
} catch (err) {
await ping('/fail');
throw err;
}
$ErrorActionPreference = 'Stop'
$ping = 'https://cronengine.com/ping/hb_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
Invoke-RestMethod "$ping/start" -TimeoutSec 10 | Out-Null
try {
& 'C:\Scripts\backup.ps1'
Invoke-RestMethod $ping -TimeoutSec 10 | Out-Null
} catch {
Invoke-RestMethod "$ping/fail" -TimeoutSec 10 | Out-Null
throw
}
Watch your sites
Give an uptime monitor an address and we request it from our side, as often as every minute on Pro and up, and judge every answer by the rules you set. How uptime monitoring works.
A homepage can load fine while checkout or the database is broken. If you can, point the monitor at a health check that tests what your site actually needs, and have it answer with an error when something is wrong.
Laravel 11 and newer
New Laravel apps already have a health route at /up. It answers 200 when the app boots and 500 when it does not.
WordPress
Check the homepage and require a word that only appears when the page really rendered, such as your site name.
// A health check that tests what the site needs,
// not only that the web server is running.
app.get('/health', async (req, res) => {
try {
await db.query('SELECT 1');
res.json({ status: 'ok' });
} catch {
res.status(503).json({ status: 'down' });
}
});
<?php
// public/health.php
try {
$pdo = new PDO($dsn, $user, $pass, [PDO::ATTR_TIMEOUT => 3]);
$pdo->query('SELECT 1');
echo 'ok';
} catch (Throwable $e) {
http_response_code(503);
echo 'down';
}
From the Starter plan up, the same monitor also watches the site’s certificate and emails you before it expires. If your firewall only lets known addresses in, allow CronEngine’s documented outbound IP.
Alerts and status pages
Every cron job, heartbeat and uptime monitor can send its alerts to one or more channels. A status page then tells everyone else.
Sent to the notification address in your settings.
Posts into the channel of your choice.
Posts into a Discord channel.
A message from your own bot, in a chat or group.
A signed JSON message to any address you run.
Sent in the type field and the X-CronEngine-Event-Type header.
job.failed
job.recovered
job.run.succeeded
job.run.failed
monitor.down
monitor.recovered
uptime.down
uptime.recovered
certificate.expiring
certificate.issuer_changed
Every request to your own webhook carries X-CronEngine-Timestamp. With a signing secret set, it also carries X-CronEngine-Signature, which is v1= followed by an HMAC-SHA256 of the timestamp, a dot and the raw body, made with your secret. Compare it before you trust the message.
How many channels one job or monitor can use at once depends on your plan, from one on Free to three on Business and up.
Read the webhook guide{
"version": "1",
"id": "evt_48213",
"type": "uptime.down",
"created_at": "2026-10-17T14:02:11Z",
"monitor": {
"id": 12,
"name": "Shop",
"url": "https://shop.example.com",
"history_url": "https://cronengine.com/uptime/history?id=12",
"state": "down"
},
"check": {
"http_status": 502,
"error": null,
"confirmations": 3,
"checked_at": "2026-10-17T14:02:11Z"
}
}
Every status page works straight away at cronengine.com/status/your-name. On Pro and up you can move it to an address of your own, such as status.yourcompany.com.
Use a subdomain. A bare domain such as yourcompany.com cannot carry this kind of record at most providers.
DNS record
| Type | Name | Points to |
|---|---|---|
| CNAME | status | pages.cronengine.com |
Then link to it
<a href="https://status.yourcompany.com">System status</a>
Ready to connect one?
Every job and monitor has a test button, so you see the first request or ping arrive before you rely on it.