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_subscriptions or smtp_send_email. You fill in a few fields and cronservices builds the request. See Integrations.

Quickstart

  1. Create an account. The Hobby plan is free and needs no card.
  2. Create an API key in Settings → API keys.
  3. Create a job:
bash
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" } }
    ]
  }'
json
{ "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):

text
┌──────── 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.

json
{ "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:

bash
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:

json
{
  "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:

json
{
  "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:

text
*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:

json
{
  "store": "{{ $.store }}",
  "skus": { "{{#each $.rows}}": { "sku": "{{ $.sku }}", "store": "{{ $root.store }}" } }
}
json
{ "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:

bash
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" } }'
json
{ "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:

json
{
  "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.