Create a cron (POST /api/crons)
POST https://app.delay.run/api/crons creates a cron that Delay runs on your schedule. The response gives you the cron’s ID and its next run time. You need an API token from Settings > API token (see API overview and authentication).
The smallest working request, an hourly health check:
curl -X POST https://app.delay.run/api/crons \ -H "Authorization: Bearer $DELAY_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{"name": "Health check", "url": "https://api.yourapp.com/health", "cron_expression": "0 * * * *"}'This cron sends a GET request at the start of every hour, UTC, with a 30-second timeout and no alerts.
Request
Send these headers with every request:
| Header | Value |
|---|---|
Authorization | Bearer followed by your API token |
Content-Type | application/json |
Accept | application/json. Without it, validation errors come back as a 302 redirect instead of a 422. |
The cron is created in your current team, the one selected in the dashboard’s team switcher. See API overview and authentication.
Body fields
| Field | Type | Required | Default | Limits and notes |
|---|---|---|---|---|
name | string | yes | Up to 255 characters. Doesn’t have to be unique. | |
url | string | yes | A valid URL, up to 2,048 characters. Must be publicly reachable. | |
cron_expression | string | yes | Five fields or a shortcut like @daily, evaluated in UTC. Up to 255 characters. See Cron schedules. | |
method | string | no | GET | GET, POST, PUT, PATCH or DELETE, uppercase |
headers | object | no | none | Header names mapped to string values, sent with every run |
payload | string | no | none | Sent as a JSON body (as query parameters for GET). A JSON object or array is sent as that JSON; any other text is wrapped as {"body": "..."}. |
callback_url | string | no | none | A valid URL, up to 2,048 characters. Delay POSTs a summary there after every run. See Callbacks. |
timeout_seconds | integer | no | 30 | 1 to 300 |
alert_threshold | integer | no | none (alerts off) | 1, 2 or 3: alert after that many consecutive failed runs. See Set up alerts. |
is_active | boolean | no | true | false creates the cron paused |
Fields that aren’t in this table are ignored, without an error. That includes success_assertions: the API can’t set assertions.
Sending a JSON body
payload is a string, not an object. To send a JSON body, encode it as a string first. In raw JSON that means escaping the inner quotes:
{ "payload": "{\"type\":\"full\"}"}Your endpoint then receives the body {"type":"full"}. In code, build the string with json_encode, JSON.stringify or json.dumps, as in the examples below. Sending an object instead gives a 422 with “The payload field must be a string.”
Examples
A nightly backup: a POST at 03:00 UTC with a header and a JSON body, a 2-minute timeout, and an alert after two failures in a row.
curl -X POST https://app.delay.run/api/crons \ -H "Authorization: Bearer $DELAY_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "name": "Nightly backup", "url": "https://api.yourapp.com/backup", "method": "POST", "cron_expression": "0 3 * * *", "headers": { "X-Api-Key": "your-endpoint-key" }, "payload": "{\"type\":\"full\"}", "timeout_seconds": 120, "alert_threshold": 2 }'<?php
$ch = curl_init('https://app.delay.run/api/crons');
curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('DELAY_TOKEN'), 'Content-Type: application/json', 'Accept: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'name' => 'Nightly backup', 'url' => 'https://api.yourapp.com/backup', 'method' => 'POST', 'cron_expression' => '0 3 * * *', 'headers' => ['X-Api-Key' => 'your-endpoint-key'], 'payload' => json_encode(['type' => 'full']), 'timeout_seconds' => 120, 'alert_threshold' => 2, ]),]);
$body = curl_exec($ch);
if ($body === false) { throw new RuntimeException('Request failed: ' . curl_error($ch));}
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($status !== 201) { throw new RuntimeException("Delay returned {$status}: {$body}");}
$cron = json_decode($body, true);echo "Created {$cron['id']}, next run at {$cron['next_run_at']}\n";// Node.js 18 or newer. Save as create-cron.mjs and run: node create-cron.mjsconst response = await fetch('https://app.delay.run/api/crons', { method: 'POST', headers: { Authorization: `Bearer ${process.env.DELAY_TOKEN}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ name: 'Nightly backup', url: 'https://api.yourapp.com/backup', method: 'POST', cron_expression: '0 3 * * *', headers: { 'X-Api-Key': 'your-endpoint-key' }, payload: JSON.stringify({ type: 'full' }), timeout_seconds: 120, alert_threshold: 2, }),});
const data = await response.json();
if (response.status !== 201) { throw new Error(`Delay returned ${response.status}: ${JSON.stringify(data)}`);}
console.log(`Created ${data.id}, next run at ${data.next_run_at}`);# pip install requestsimport jsonimport os
import requests
response = requests.post( "https://app.delay.run/api/crons", headers={ "Authorization": f"Bearer {os.environ['DELAY_TOKEN']}", "Accept": "application/json", }, json={ "name": "Nightly backup", "url": "https://api.yourapp.com/backup", "method": "POST", "cron_expression": "0 3 * * *", "headers": {"X-Api-Key": "your-endpoint-key"}, "payload": json.dumps({"type": "full"}), "timeout_seconds": 120, "alert_threshold": 2, }, timeout=30,)
if response.status_code != 201: raise RuntimeError(f"Delay returned {response.status_code}: {response.text}")
cron = response.json()print(f"Created {cron['id']}, next run at {cron['next_run_at']}")Each example reads the token from the DELAY_TOKEN environment variable. Set it first, for example export DELAY_TOKEN=your-token.
Response
A new cron returns 201 Created:
{ "id": "01a14f38-a170-70cb-bbb8-6b807a0ef65e", "name": "Nightly backup", "cron_expression": "0 3 * * *", "next_run_at": "2026-10-19T03:00:00+00:00", "is_active": true}| Field | Type | Description |
|---|---|---|
id | string | The cron’s UUID |
name | string | The name you sent |
cron_expression | string | The expression you sent |
next_run_at | string or null | The next run time, ISO 8601 in UTC. null when the cron was created paused (is_active: false). |
is_active | boolean | Whether the cron is running |
The response escapes forward slashes, so */5 * * * * comes back as "*\/5 * * * *". Any JSON parser turns it back into */5 * * * *.
Errors
Checks happen in this order: the rate limit, then the body, then the token. A request with a bad body and a bad token gets 422, not 401.
401 Unauthorized
The token is missing or doesn’t match any user. The body is always JSON:
{ "error": "Missing authorization token" }{ "error": "Invalid webhook token" }“Invalid webhook token” means the API token is wrong or was regenerated. Copy the current one from Settings > API token.
422 Unprocessable Content
The body failed validation. message repeats the first error, and errors lists every problem by field:
{ "message": "A name for the cron job is required. (and 2 more errors)", "errors": { "name": ["A name for the cron job is required."], "url": ["The target URL must be a valid URL."], "cron_expression": ["The cron expression is not a valid cron expression."] }}Messages you may see:
| Field | Message |
|---|---|
name | ”A name for the cron job is required.” |
url | ”A target URL is required.” or “The target URL must be a valid URL.” |
method | ”The method must be one of: GET, POST, PUT, PATCH, DELETE.” (lowercase get is rejected too) |
cron_expression | ”A cron expression is required.”, “The cron expression is not a valid cron expression.” or “The cron expression never produces a valid run time.” |
payload | ”The payload field must be a string.” |
timeout_seconds | ”Timeout must be at least 1 second.” or “Timeout cannot exceed 300 seconds (5 minutes).” |
alert_threshold | ”The alert threshold must be 1, 2, or 3.” |
You only get a 422 when the request has Accept: application/json. Without it, the same errors come back as a 302 redirect with an HTML body, which is easy to mistake for success.
429 Too Many Requests
The API allows 60 requests per minute per client IP address. The limit is shared by POST /api/crons and POST /api/jobs, so 60 jobs created in one minute also block cron creation from that address for the rest of the minute. It doesn’t depend on your token or team.
{ "message": "Too Many Attempts." }The response includes Retry-After (seconds to wait), X-RateLimit-Limit and X-RateLimit-Remaining. Every response also carries the last two, so you can slow down before you hit the limit. When creating many crons in a loop, wait for Retry-After on a 429, or send at most one request per second.
What this endpoint can’t do
- Set success assertions. Create the cron, then add them in the dashboard with Edit. See Success assertions.
- Update, pause, delete, list or run a cron. There are no API endpoints for these. Use the dashboard: see Schedule a recurring request.
- Prevent duplicates. Every successful call creates a new cron, even with the same name. If a request times out on your side, check the Cron jobs list before sending it again.
- Use a timezone. Expressions are always UTC.
- Check your usage. A team over its monthly limit still gets
201. The cron’s runs then fail withlimit_exceeded. See How usage is counted.
Next steps
- Cron schedules: syntax, UTC, timing and overlapping runs.
- POST /api/jobs: send a one-off or delayed request instead.
- Verify requests from Delay: check that a request came from Delay.
- API overview and authentication: tokens, teams and errors common to both endpoints.