What we send
Each call is a plain HTTP request using the method, headers and body you configured, plus these headers added by Cronvella:
| Header | Meaning |
|---|---|
User-Agent | Cronvella/1.0 (+https://cronvella.com) |
X-Cronvella-Job-Id | The numeric ID of the job being run. |
X-Cronvella-Scheduled-For | The time the run was meant to happen, in UTC (ISO 8601). |
X-Cronvella-Signature | A timestamp and an HMAC signature, described below. |
If your job has a body and you have not set a Content-Type header, we send application/json when the body is valid JSON and text/plain otherwise. The headers in the table above are always set by us: a header with the same name in your job is ignored.
Verify the signature
Anyone can send a request to a public URL, so check the signature before you act on one. Every job has its own signing secret, shown on the job's page. Keep it private.
The signature header looks like this:
X-Cronvella-Signature: t=1767225600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd- Read
t(a Unix timestamp) andv1from the header. - Build the signed string: the timestamp, a full stop, then the raw request body. For a request with no body, the body part is empty.
- Compute
HMAC-SHA256of that string using your signing secret, as lowercase hex. - Compare it with
v1using a constant-time comparison. - Reject the request if
tis more than a few minutes old, which stops someone replaying a captured request.
Use the raw body. Verify against the exact bytes received, before any JSON parsing or reformatting, or the signature will not match.
Example Laravel route
Put the secret in your .env file and read it through config. In routes/web.php (or routes/api.php), exempt the route from CSRF protection, since it is called by a server and not a browser.
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/hooks/cronvella', function (Request $request) {
$header = $request->header('X-Cronvella-Signature', '');
// "t=1767225600,v1=abcdef..." becomes ['t' => '1767225600', 'v1' => 'abcdef...']
parse_str(str_replace(',', '&', $header), $parts);
$timestamp = $parts['t'] ?? '';
$signature = $parts['v1'] ?? '';
$expected = hash_hmac(
'sha256',
$timestamp.'.'.$request->getContent(),
config('services.cronvella.secret'),
);
abort_unless(hash_equals($expected, $signature), 401);
abort_if(abs(time() - (int) $timestamp) > 300, 401); // older than 5 minutes
// Verified. Do the scheduled work here.
return response()->noContent();
});In bootstrap/app.php, add the route to the CSRF exceptions with $middleware->validateCsrfTokens(except: ['hooks/cronvella']);.
Success and retries
- A run succeeds when the status code matches the job's expected status. By default that is any
2xx. You can set specific codes, such as200,204. - Anything else is a failure: another status code, a timeout, or a connection, DNS or blocked-address error.
- Failed calls are retried up to the job's attempt limit, waiting 10 seconds, then 1 minute, then 5 minutes. We do not retry blocked addresses, or
4xxresponses other than408and429. - Each attempt is recorded on its own. After the final failed attempt we email you, and we email once more when the job recovers.
- Redirects are not followed. A
3xxresponse is recorded as the result, so give us the final URL. - With overlap set to skip, a run that is due while the previous one is still going is recorded as skipped.
Alerts and heartbeats
- We email you once when a job starts failing and once when it recovers, not for every failed run. You can turn these emails off on your account page.
- A job can have a heartbeat URL. After each run we send a plain
GETto it, or to the same URL with/failadded to the path if the run failed. This matches Pingvella and healthchecks.io. Retries and skipped runs do not ping. The heartbeat URL gets the same address checks as the job URL, and no signature or job data is sent to it.
Network and addresses
- We call only
httpandhttpsURLs on ports 80 and 443, without credentials in the URL. - Hostnames must resolve to public internet addresses. Localhost, private ranges, link-local addresses and cloud metadata addresses are blocked.
- Identify us by the
User-Agentabove and the signature. - We do not publish fixed source IP addresses, so rely on the signature rather than the address. If you need to allow-list us, get in touch.
Limits
These depend on your plan. See pricing for the current values.
- Number of jobs, and the shortest interval between runs.
- The longest time we wait for a response (the timeout).
- The size of the request body.
- How long run history is kept. We store only the first 2 KB of each response.