# 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](/integrations).

## Quickstart

1. [Create an account](/login#signup). 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](/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](#credentials), 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](/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](#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](#steps).

**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](/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](#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.
