Docs
cronservices calls HTTP endpoints on a schedule, or once at an exact time. A job pairs a schedule (a cron expression in any time zone, or a single run_at timestamp) with a chain of steps. Each step receives the previous step's output, so one job can fetch data, reshape it, and post it somewhere else. That's the work a scheduled serverless function usually does.
A step is one of three things:
- a request to any HTTP endpoint,
- a transform that reshapes JSON without making a request, or
- a built-in step for a known API, such as
slack_send_message,stripe_list_subscriptionsorsmtp_send_email. You fill in a few fields and cronservices builds the request. See Integrations.
Quickstart
- Create an account. The Hobby plan is free and needs no card.
- Create an API key in Settings → API keys.
- Create a job:
curl -X POST https://api.cron.services/alarms \
-H "Authorization: Bearer $CRONSERVICES_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Warm product cache",
"cron": { "expression": "*/15 * * * *" },
"nodes": [
{ "id": "warm", "request": { "method": "POST", "endpoint": "https://shop.example.com/api/cache/warm" } }
]
}'{ "id": "6f1c1f0e-2a4b-4d55-9a0e-7b1f3c2d9e10" }cronservices now sends POST https://shop.example.com/api/cache/warm every 15 minutes and records every run. To try it immediately, call POST /alarms/{id}/run, which runs the job now and returns the run.
Schedules
A repeating job has a cron object. expression is a standard 5-field cron expression, read as wall-clock time in time_zone (an IANA name such as America/New_York; defaults to UTC):
┌──────── minute 0-59
│ ┌────── hour 0-23
│ │ ┌──── day of month 1-31
│ │ │ ┌── month 1-12 or JAN-DEC
│ │ │ │ ┌ day of week 0-6 or SUN-SAT (0 is Sunday)
│ │ │ │ │
* * * * *Each field takes *, a value, a range (1-5), a list (1,15), or a step (*/10, 0-30/5). A few extensions cover schedules plain cron can't express:
| Expression | Fires |
|---|---|
*/5 * * * * |
Every 5 minutes |
0 9 * * 1-5 |
09:00 on weekdays |
30 2 * * SUN |
02:30 every Sunday |
0 0 1 * * |
Midnight on the 1st of each month |
0 0 L * * |
Midnight on the last day of each month |
30 17 LW * * |
17:30 on the last weekday of each month |
0 9 15W * * |
09:00 on the weekday nearest the 15th |
0 8 * * MON#1 |
08:00 on the first Monday of each month |
0 16 * * FRI#L |
16:00 on the last Friday of each month |
When both day-of-month and day-of-week are set (neither is *), the job fires on days that match either one, as in classic crontab.
{ "cron": { "expression": "0 9 * * 1-5", "time_zone": "Europe/Berlin" } }Daylight saving time. A job at 09:00 in America/New_York fires at 09:00 local all year. A time skipped when the clocks go forward fires just after the change, and a time repeated when they go back fires once.
The fastest schedule is every minute (* * * * *). There's no seconds field, and @daily-style shortcuts aren't supported.
One-time jobs
To fire once, send run_at instead of cron. It's an ISO 8601 timestamp with an offset, in the future and within a year:
curl -X POST https://api.cron.services/alarms \
-H "Authorization: Bearer $CRONSERVICES_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Trial ends: acme",
"run_at": "2026-12-01T09:00:00-05:00",
"nodes": [
{ "id": "expire", "request": { "method": "POST", "endpoint": "https://api.example.com/trials/acme/expire", "credential_id": "YOUR_CREDENTIAL_ID" } }
]
}'The job fires within a few seconds of run_at, then sets complete_time and enabled: false. Its run stays in the job's history, and it no longer counts toward your plan's job limit. To run it again, PUT it with a new run_at.
Steps
A job's nodes is a list of 1 to 20 steps, and at least one must make a request. Each step has an id (letters, digits, _ and -, unique within the job), an optional name, and exactly one kind:
| Kind | Does | Output, which the next step gets as $ |
|---|---|---|
request |
Sends an HTTP request. The URL, headers, and body can use placeholders | The response body, parsed as JSON when it parses, otherwise text |
transform |
Renders a JSON template. No network | The rendered JSON |
| A built-in step | Sends the request a known API expects (see Integrations) | The API's response |
The first step gets null as its input. A run stops at the first step that fails: a network error, a timeout, or a non-2xx status. The run records which step stopped it and why.
This job posts open pull requests to Slack every weekday at 09:00 Berlin time:
{
"name": "Open PRs to #eng",
"cron": { "expression": "0 9 * * 1-5", "time_zone": "Europe/Berlin" },
"nodes": [
{ "id": "prs", "name": "Open PRs",
"github_list_pull_requests": { "credential_id": "YOUR_GITHUB_CREDENTIAL_ID", "owner": "acme", "repo": "web", "state": "open" } },
{ "id": "lines", "name": "One line per PR",
"transform": { "template": "{ \"count\": \"{{ $.length }}\", \"lines\": { \"{{#each $}}\": \"• <{{ $.html_url }}|#{{ $.number }}> {{ $.title }} ({{ $.user.login }})\" } }" } },
{ "id": "post", "name": "Post to Slack",
"slack_send_message": { "webhook_url": "https://hooks.slack.com/services/T000/B000/XXXX", "text": "*{{ $.count }} open pull requests*\n{{ join($.lines, \"\\n\") }}" } }
]
}The transform turns GitHub's array of pull requests into:
{
"count": 2,
"lines": [
"• <https://github.com/acme/web/pull/412|#412> Fix retry backoff (ada)",
"• <https://github.com/acme/web/pull/415|#415> Add pipeline editor (grace)"
]
}and the Slack step posts:
*2 open pull requests*
• <https://github.com/acme/web/pull/412|#412> Fix retry backoff (ada)
• <https://github.com/acme/web/pull/415|#415> Add pipeline editor (grace)The equivalent serverless function would need a deploy target, a cron trigger, secrets, a GitHub client, error handling, and logs. Here it's one JSON document, and every run is recorded.
Request steps
| Field | Type | Notes |
|---|---|---|
method |
string | GET, POST, PUT, PATCH or DELETE. Defaults to POST. Responses spell it HTTP_METHOD_GET and so on, and requests accept either form |
endpoint |
string | Full http:// or https:// URL. May contain placeholders |
credential_id |
string | Optional. A bearer_token, basic_auth or api_key_header credential, sent as its header |
headers |
array | [{ "name": "X-Source", "value": "cronservices" }]. Values may contain placeholders. Up to 50 |
body |
string | Sent with POST, PUT, PATCH and DELETE. Up to 64 KiB |
A body that starts with { or [ is a JSON template and must be valid JSON once its placeholders are filled in, and it's sent as application/json unless you set Content-Type. Any other body is plain text with placeholders filled in, sent as text/plain.
Transform steps
| Field | Type | Notes |
|---|---|---|
template |
string | Required. A JSON template, rendered against the previous step's output |
Use a transform to pick a few fields, rename them, or build a list that a later step sends or joins into text.
Built-in steps
A built-in step takes only the fields its API needs, and cronservices builds and signs the request. Each one counts as a request: a non-2xx response stops the job.
| Group | Steps |
|---|---|
| Chat | slack_send_message, discord_send_message, teams_post_message, google_chat_send_message, mattermost_send_message, telegram_send_message |
| Push | ntfy_publish, pushover_send_notification, gotify_send_message, pushbullet_push_note |
| Email and SMS | smtp_send_email, resend_send_email, sendgrid_send_email, twilio_send_sms |
| Incidents and monitoring | pagerduty_trigger_incident, opsgenie_create_alert, datadog_post_event, healthchecks_ping |
| Developer tools | github_create_issue, ifttt_trigger |
| Fetch data | stripe_list_subscriptions, stripe_list_customers, stripe_retrieve_balance, github_list_pull_requests, slack_channel_history, pagerduty_list_on_calls, open_meteo_forecast, frankfurter_latest |
Each service's page under Integrations lists its fields and a complete example job. Text fields such as a message or subject are filled in as text, so "Rain: {{ $.daily.precipitation_sum[0] }} mm" works anywhere.
Templates
Placeholders use double braces and read from the previous step's output.
| Placeholder | Value |
|---|---|
{{ $ }} |
The whole previous output |
{{ $.user.login }} |
A nested field |
{{ $.items[0] }} |
A list element |
{{ $["first name"] }} |
A key with spaces or symbols |
{{ $.items.length }} |
The length of a list, or of a string |
{{ $root.x }} |
The step's input, even inside #each |
{{ join($.names, ", ") }} |
A list as text. The separator defaults to ", "; use "\n" for one item per line |
{{ now() }} |
When the job runs, as an ISO 8601 string in UTC |
{{ now:America/New_York }} |
That same instant in an IANA time zone |
{{ today("Europe/London") }} |
Midnight at the start of the run's day in that zone (UTC without an argument) |
{{ format(now(), "YYYY-MM-DD") }} |
The run time in a layout. See Formats |
{{ now() - days(14) }} |
Date math. Also hours, minutes, seconds, weeks, months and years |
{{ $.created_at + days(1) }} |
An ISO 8601 time from the input, plus a duration |
{{ unix(now()) }} |
Seconds since 1970. time(1793188800) goes the other way |
Where they work: request URLs, header values and bodies, transform templates, and the text fields of built-in steps.
Types are kept when a placeholder is the entire JSON value. "{{ $.count }}" and a bare {{ $.count }} both render as the number 2. A placeholder inside longer text becomes text: "Total: {{ $.count }}" renders as "Total: 2".
Lists: an object whose only key is {{#each <list>}} becomes an array. Its value is rendered once per item, with $ bound to the item:
{
"store": "{{ $.store }}",
"skus": { "{{#each $.rows}}": { "sku": "{{ $.sku }}", "store": "{{ $root.store }}" } }
}{ "store": "nyc", "skus": [{ "sku": "a", "store": "nyc" }, { "sku": "b", "store": "nyc" }] }To send a list as a chat message or an email, build a list of lines with #each in a transform step, then join it in the message, as in the pull request example.
Times. now() is when the job runs, in UTC. now:America/New_York is that same instant written in an IANA time zone, offset included. Add or subtract a duration, with spaces around + and -: now() - days(14), now:America/Los_Angeles + hours(9). days and months follow that time zone's calendar. A field that is already an ISO 8601 timestamp shifts the same way.
Formats
format(now(), "YYYY-MM-DD") writes a layout instead of ISO 8601, and an optional third argument picks the zone: format(now(), "dddd, MMMM DD", "America/Los_Angeles"). format(now:America/Los_Angeles - days(1), "MM/DD/YYYY") is yesterday in that zone.
| Token | Writes | Example |
|---|---|---|
YYYY / YY |
Year | 2026 / 26 |
MM / MMM / MMMM |
Month | 10 / Oct / October |
DD |
Day of the month | 05 |
ddd / dddd |
Weekday | Wed / Wednesday |
HH / hh |
Hour, 00–23 / 01–12 | 14 / 02 |
mm / ss / SSS |
Minute / second / millisecond | 05 / 09 / 000 |
A |
AM or PM |
PM |
Z |
Offset | Z or -05:00 |
Text in [brackets] is copied as is: "ddd [at] hh:mm A" writes Wed at 02:05 PM.
Missing values render as null (or an empty string inside text) and are flagged in the editor preview. They don't fail the run.
Credentials
A credential is a saved secret that any number of jobs can use. Steps refer to it by credential_id, so the secret never appears in a job, a template, or the run history. Create credentials in Settings → Credentials, or through the API:
curl -X POST https://api.cron.services/credentials \
-H "Authorization: Bearer $CRONSERVICES_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "GitHub (acme)", "bearer_token": { "token": "ghp_your_token" } }'{ "id": "Xq3vK8mB2nT5wR7yL1pA" }| Kind | Fields | Used by |
|---|---|---|
bearer_token |
token |
Request steps (as Authorization: Bearer), and the Stripe, GitHub, Slack history, Resend, SendGrid, Pushbullet and ntfy steps |
basic_auth |
username, password |
Request steps (as Authorization: Basic) |
api_key_header |
header, value |
Request steps (as <header>: <value>, such as X-API-Key) |
smtp |
host, port, security (tls or starttls), username, password, from_address, from_name |
smtp_send_email |
twilio |
account_sid, auth_token |
twilio_send_sms |
telegram_bot |
bot_token |
telegram_send_message |
pushover |
api_token |
pushover_send_notification |
gotify |
server, token |
gotify_send_message |
pagerduty_routing_key |
routing_key |
pagerduty_trigger_incident |
pagerduty_api_key |
api_key |
pagerduty_list_on_calls |
opsgenie |
region, api_key |
opsgenie_create_alert |
datadog |
site, api_key |
datadog_post_event |
ifttt |
key |
ifttt_trigger |
Secret fields (tokens, passwords, keys) are write-only. They're encrypted at rest and never returned, and reads show last4 instead. Updating a credential with an empty secret keeps the stored one. Editing a credential changes what every job using it sends, starting with its next run. A credential can't be deleted while a job uses it.
API
Base URL: https://api.cron.services. Send JSON, and authenticate every request with Authorization: Bearer <API key>. The machine-readable spec is at /openapi.json. In the API, jobs are called alarms and steps are nodes.
| Method | Path | Does |
|---|---|---|
GET |
/alarms |
List your jobs, newest first |
POST |
/alarms |
Create a job. Returns 201 { "id": "…" } |
GET |
/alarms/{id} |
Get one job |
PUT |
/alarms/{id} |
Replace a job. Send every field. Returns 204 |
DELETE |
/alarms/{id} |
Delete a job and its run history. Returns 204 |
POST |
/alarms/{id}/run |
Run the job now, outside its schedule, and return the finished run. Works on paused jobs and doesn't move the schedule |
GET |
/alarms/{id}/runs |
The job's runs, newest first. ?page_size= (up to 500, default 100) and ?page_token= |
GET |
/credentials |
List credentials, without their secrets |
POST |
/credentials |
Create a credential. Returns 201 { "id": "…" } |
PUT |
/credentials/{id} |
Replace a credential. An empty secret keeps the stored one. Returns 204 |
DELETE |
/credentials/{id} |
Delete a credential. Returns 204, or 409 while a job uses it |
GET |
/health |
Health check. No auth |
Job fields
| Field | Type | Notes |
|---|---|---|
id |
string | UUID. Read-only |
name |
string | Up to 80 characters |
cron |
object | { "expression": "0 9 * * *", "time_zone": "America/New_York" }. Send this or run_at |
run_at |
string | ISO 8601 time for a one-time job, within a year. Send this or cron |
nodes |
array | 1 to 20 steps. See Steps |
enabled |
boolean | false pauses the job. Defaults to true |
next_fire_at |
string | Read-only. The next scheduled run. Absent once a one-time job has fired |
last_fired_at |
string | Read-only |
last_status_code |
integer | Read-only. The last request's status, where 0 means it couldn't be sent |
complete_time |
string | Read-only. When a one-time job fired |
create_time, update_time |
string | Read-only |
Run fields
| Field | Type | Notes |
|---|---|---|
id, alarm_id |
string | |
trigger |
string | RUN_TRIGGER_SCHEDULE, or RUN_TRIGGER_MANUAL for POST /alarms/{id}/run |
fire_time |
string | When the run started |
duration |
string | Such as "0.412s" |
status_code |
integer | The last request's status. 0 means it couldn't be sent (DNS, TLS, or the 10-second timeout) |
failed_node_id |
string | The step that stopped the run. Absent when it succeeded |
error_message |
string | Why it failed, such as HTTP 401: {"message":"Bad credentials"}. Absent when it succeeded |
steps |
array | One entry per request or built-in step: node_id, node_name, status (RUN_STEP_STATUS_SUCCEEDED, …_FAILED or …_SKIPPED), status_code, duration, error_message |
Fields that are empty, zero, or unset are left out of responses.
Errors
Errors return JSON with a stable code:
{
"code": "validation_failed",
"message": "Some fields are invalid",
"fields": { "alarm.cron.expression": "hour must be 0-23, *, or a list, range or step of those" }
}| Status | code |
Means |
|---|---|---|
400 |
invalid_json |
The body isn't valid JSON for this request, such as an unknown field or a timestamp without an offset. message says where |
401 |
unauthenticated |
Missing or invalid API key |
402 |
plan_limit |
A plan limit is reached |
403 |
email_not_verified |
The account's email isn't verified yet |
404 |
not_found |
Nothing with that id on this account |
409 |
failed_precondition |
The change conflicts with current state, such as deleting a credential a job uses |
422 |
validation_failed |
See fields, keyed by path: alarm.nodes[1].transform.template, or alarm for rules that span fields |
500 |
internal |
Our fault. Retry with backoff |
Limits
| Limit | Value |
|---|---|
| Request timeout | 10 seconds per request or built-in step |
| Steps per job | 20 |
| Request body, template | 64 KiB each |
| Schedule resolution | 1 minute |
One-time run_at |
Up to one year ahead, any second |
| Scheduler drift | A job fires within about 5 seconds of its scheduled time |
A request that takes longer than 10 seconds is recorded with status 0. For longer work, have the endpoint accept the request, return 202, and finish in the background.