---
title: "Schedules · Scrape.ly Docs"
description: "Run Actors and tasks on a cron schedule, as a view of your automations, and read each schedule log."
url: https://scrape.ly/docs/api-schedules
markdown: https://scrape.ly/docs/api-schedules.md
---

# Schedules

> Run Actors and tasks on a cron schedule, as a view of your automations, and read each schedule log.

Schedules start Actors or tasks on a recurring basis using cron expressions. All endpoints are prefixed with `https://api.scrape.ly/v2`. Authenticate with `Authorization: Bearer $SCRAPELY_TOKEN`.

Creating, updating, and deleting schedules needs an API key: the Default key, a full-access key, or a scoped key with `schedules:write`, `actors:run`, and the run permission `actor-default` (see [API keys & permissions](https://scrape.ly/docs/api-keys.md#scopes)). Actor run tokens receive `403 insufficient-permissions`. Reading schedules and their logs needs `schedules:read` on a scoped key.

There is no endpoint to trigger a schedule manually. To run its Actor or task now, use the [Actors API](https://scrape.ly/docs/api-actors.md) or [Tasks API](https://scrape.ly/docs/api-tasks.md), or **Run now** on the [automation](https://scrape.ly/docs/api-automations.md#run-now) with the same ID.

## Schedules are automations

Every schedule is an [automation](https://scrape.ly/docs/automations.md) with a schedule trigger, and the same ID works in both APIs. `/v2/schedules` is a view of your automations in the shape existing schedule clients expect:

- It lists only automations with a **schedule** trigger. Automations that run manually or after another run finishes are not listed here, and `GET`, `PUT`, `DELETE`, and `/log` on them return `404`. Use the [Automations API](https://scrape.ly/docs/api-automations.md) for those.
- An automation's filters, AI step, and deliveries appear under one extra key, `automation`, which is only present when it has any of them. It never contains secrets:

```json
"automation": {
  "hasAiStep": true,
  "filters": { "onlyWithResults": false, "onlyNewItems": { "keyFields": ["url"] }, "conditions": [] },
  "deliveries": [ { "channel": "email" }, { "channel": "webhook" } ]
}
```

- A `PUT` changes only the fields you send. Replacing `actions` replaces the run steps and keeps the automation's AI step, filters, and deliveries. To change those, use [Update automation](https://scrape.ly/docs/api-automations.md#update-automation).
- `DELETE` also deletes the automation's run history.
- `notificationsEnabled` is the automation's failure alert (`options.notifyOnFailure` in the Automations API). Both read and change the same setting.

## Endpoints

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v2/schedules` | List schedules you own |
| `POST` | `/v2/schedules` | Create a schedule |
| `GET` | `/v2/schedules/{scheduleId}` | Get a schedule |
| `PUT` | `/v2/schedules/{scheduleId}` | Update a schedule |
| `DELETE` | `/v2/schedules/{scheduleId}` | Delete a schedule |
| `GET` | `/v2/schedules/{scheduleId}/log` | Get the schedule log |

## Errors and limits

| Status | `error.type` | When |
| --- | --- | --- |
| `400` | `invalid-input` | Missing `name`, `cronExpression`, or `actions`; more than 10 Actor or 10 task actions; a `RUN_ACTOR` action without `actorId`; a negative `offset` or `limit`. |
| `400` | `invalid-cron-expression` | The cron expression is not valid, or it never fires (for example `0 0 30 2 *`). See [Cron expressions](#cron-expressions). |
| `400` | `invalid-timezone` | `timezone` is not an IANA time zone name. |
| `400` | `duplicate-name` | You already have a schedule with this name. |
| `400` | `actor-not-built` | One of your scheduled Actors has no successful build yet. |
| `400` | `actor-not-available` | A scheduled Actor of someone else's can't be run right now. |
| `400` | `task-not-found` | A `RUN_ACTOR_TASK` action names a task you don't own. |
| `403` | `insufficient-permissions` | Creating, updating, or deleting with an Actor run token, or with an API key whose scopes don't cover it. |
| `403` | `limit-reached` | Creating a schedule when you already have your plan's maximum number of schedules. Schedules and automations share one limit. |
| `403` | `forbidden` | The schedule belongs to another account. |
| `404` | `record-not-found` | The schedule does not exist, or it is an automation without a schedule trigger. |
| `404` | `record-or-token-not-found` | A `RUN_ACTOR` action names an Actor that does not exist. |

## Get list of schedules

```
GET /v2/schedules
```

| Query param | Type | Description |
| --- | --- | --- |
| `search` | string | Substring match on the schedule name. |
| `sortBy` | string | `createdAt` (default), `name`, `isEnabled`, `lastRunAt`, or `nextRunAt`. |
| `desc` | boolean | `1` or `true` reverses the sort order. Default is ascending. |
| `offset` | number | Number of schedules to skip (default `0`). |
| `limit` | number | Page size (default `1000`, max `1000`). |

```bash [Request]
curl -H "Authorization: Bearer $SCRAPELY_TOKEN" \
  "https://api.scrape.ly/v2/schedules?search=daily&sortBy=nextRunAt&desc=1&limit=20"
```

```json [Response]
{
  "data": {
    "total": 1,
    "count": 1,
    "offset": 0,
    "limit": 20,
    "desc": true,
    "items": [
      {
        "id": "9d4b2f6e-7a1c-4e3b-8f5d-2c0a1b9e7d3f",
        "userId": "uSrIdExAmPlE123",
        "name": "daily-scrape",
        "title": null,
        "description": null,
        "cronExpression": "0 6 * * *",
        "timezone": "UTC",
        "isEnabled": true,
        "isExclusive": false,
        "notificationsEnabled": false,
        "consoleNotificationsEnabled": true,
        "createdAt": "2026-08-14T15:00:00.000Z",
        "modifiedAt": "2026-08-14T15:00:00.000Z",
        "nextRunAt": "2026-08-15T06:00:00.000Z",
        "lastRunAt": null,
        "actions": [
          {
            "id": "4f2a9c1e7",
            "actorTaskId": "kQ7mN2pXv9LsT4wRb",
            "taskName": "example-com-daily",
            "input": { "body": "{\"url\":\"https://example.com\"}", "contentType": "application/json" }
          }
        ]
      }
    ]
  }
}
```

`nextRunAt` is `null` while the schedule is disabled.

```api-test
{ "method": "GET", "path": "/v2/schedules?limit=5&sortBy=createdAt" }
```

## Create schedule

```
POST /v2/schedules
```

| Body field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string or null | Yes | Schedule name, unique among your schedules. The field must be present; `null` is allowed. |
| `cronExpression` | string | Yes | Five-field cron expression or macro such as `@daily`, evaluated in `timezone`. For example, `0 6 * * *` means daily at 06:00. See [Cron expressions](#cron-expressions). |
| `actions` | array | Yes | Non-empty list of [actions](#actions). |
| `timezone` | string | No | IANA time zone name such as `Europe/Berlin` (default `UTC`). The cron expression is evaluated in this time zone, including daylight saving. An unknown name returns `400 invalid-timezone`. |
| `isEnabled` | boolean | No | Whether the schedule fires (default `true`). |
| `isExclusive` | boolean | No | Skip a firing while the previous firing's runs are still going (default `false`). |
| `title` | string | No | Display title. |
| `description` | string | No | Description. |
| `notificationsEnabled` | boolean | No | The schedule's failure alert. Returned as `true` unless you turned it off. See the note below. |
| `consoleNotificationsEnabled` | boolean | No | Stored and returned (default `true`). |

> [!NOTE]
> When a scheduled run fails to start, Scrape.ly sends one failure alert, by email and in the dashboard according to your [notification settings](https://scrape.ly/docs/notifications.md#schedules-and-automations). `notificationsEnabled: false` turns it off for this schedule. `consoleNotificationsEnabled` doesn't change it.

```bash
curl -X POST https://api.scrape.ly/v2/schedules \
  -H "Authorization: Bearer $SCRAPELY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "daily-scrape",
    "cronExpression": "0 6 * * *",
    "actions": [
      { "type": "RUN_ACTOR_TASK", "actorTaskId": "kQ7mN2pXv9LsT4wRb" }
    ]
  }'
```

Returns `201` with `{ "data": { ...schedule } }`. Actions in the response are [normalized](#actions).

## Actions

Each action is `RUN_ACTOR` or `RUN_ACTOR_TASK`; a missing `type` means `RUN_ACTOR`. A schedule can have at most 10 actions of each type, and every scheduled Actor must have at least one successful build. All actions start together each time the schedule fires.

| Action field | Type | Description |
| --- | --- | --- |
| `type` | string | `RUN_ACTOR` or `RUN_ACTOR_TASK`. Not returned in responses. |
| `actorId` | string | Actor to run (`RUN_ACTOR`, required). |
| `actorTaskId` | string | Task to run (`RUN_ACTOR_TASK`). |
| `runInput` | object | `{ "body": "<input as a string>", "contentType": "application/json" }`. Bodies with a JSON content type are parsed; others are passed as a string. `input` is accepted as an alias. On a `RUN_ACTOR_TASK` action, a JSON object is merged over the task's input (see below). |
| `runOptions` | object | `build`, `timeoutSecs`, `memoryMbytes`, `maxItems`, `maxTotalChargeUsd`, and `restartOnError`. `build` is ignored on someone else's Actor, which runs the [User Build](https://scrape.ly/docs/publishing.md#user-build), and isn't saved or returned for it. |

When a scheduled run starts, each setting falls back in order:

- Input: for a `RUN_ACTOR` action, the action's `runInput`, then your default saved input for the Actor, then the prefill and default values from the Actor's input schema. For a `RUN_ACTOR_TASK` action, the task's input as it is when the schedule fires, with the action's `runInput` merged over it one level deep: top-level fields in `runInput` replace the task's, the other fields stay as saved. To change a nested value, send the whole top-level field.
- Options: the action's `runOptions`, then the task's options, then the Actor's defaults, then build `latest`, a 3,600-second timeout, and 2,048 MB of memory. `maxTotalChargeUsd` falls back to the Actor's minimum.

Scheduled runs have `origin` set to `SCHEDULER`.

Responses normalize actions and give each one a 9-character `id`:

- **`RUN_ACTOR_TASK`** returns `id`, `actorTaskId`, `taskName` (when known), and the stored input as `input`. `runOptions` is not returned but is still used.
- **`RUN_ACTOR`** returns `id`, `actorId`, `actorName` (when known), `runInput`, and `runOptions`.

A `RUN_ACTOR` action on create:

```json
{
  "type": "RUN_ACTOR",
  "actorId": "eLN5rTB8sYTBWdfzx",
  "runInput": { "body": "{\"startUrls\":[{\"url\":\"https://example.com\"}]}", "contentType": "application/json" },
  "runOptions": { "memoryMbytes": 2048, "timeoutSecs": 3600 }
}
```

## Cron expressions

`cronExpression` has five space-separated fields, evaluated in the schedule's `timezone`:

| Field | Values |
| --- | --- |
| Minute | `0`-`59` |
| Hour | `0`-`23` |
| Day of month | `1`-`31` |
| Month | `1`-`12` or `JAN`-`DEC` |
| Day of week | `0`-`7` or `SUN`-`SAT` (`0` and `7` are Sunday) |

Each field can be `*`, a number (`5`), a range (`1-5`), a list (`1,15,30` or `1-5,10`), or a step: `*/15`, `1-30/5`, or `5/15` (from 5 to the end of the range, every 15). Names are case-insensitive. `?` means the same as `*` in the day fields.

Instead of the five fields, you can use a macro:

| Macro | Same as |
| --- | --- |
| `@yearly`, `@annually` | `0 0 1 1 *` |
| `@monthly` | `0 0 1 * *` |
| `@weekly` | `0 0 * * 0` |
| `@daily`, `@midnight` | `0 0 * * *` |
| `@hourly` | `0 * * * *` |

The rules follow standard (Vixie) cron:

- When both day of month and day of week are restricted, a day matches if **either** matches. `0 9 1 * MON` fires on the 1st of the month and on every Monday.
- Daylight saving: a schedule with a fixed minute and hour fires once when the clock falls back, on the first occurrence of that time. If the clock springs forward over that time, it fires right after the jump. Schedules with `*` or a step in the minute or hour field follow elapsed time, so they have no runs in the skipped hour and run again in the repeated hour.

| Expression | Fires |
| --- | --- |
| `*/15 * * * *` | Every 15 minutes |
| `0 6 * * *` | Daily at 06:00 in `timezone` |
| `30 8 * * MON-FRI` | Weekdays at 08:30 |
| `0 0 1 * *` | Midnight on the 1st of each month |
| `@daily` with `timezone` `Europe/Berlin` | Midnight Berlin time (22:00 or 23:00 UTC) |

The scheduler checks for due schedules once a minute, so runs start up to about a minute after the scheduled time. Times missed while the scheduler was down are skipped, not caught up.

## Get schedule

```
GET /v2/schedules/{scheduleId}
```

```bash
curl -H "Authorization: Bearer $SCRAPELY_TOKEN" \
  https://api.scrape.ly/v2/schedules/9d4b2f6e-7a1c-4e3b-8f5d-2c0a1b9e7d3f
```

Returns `{ "data": { ...schedule } }` with normalized `actions`.

```api-test
{ "method": "GET", "path": "/v2/schedules/9d4b2f6e-7a1c-4e3b-8f5d-2c0a1b9e7d3f" }
```

## Update schedule

```
PUT /v2/schedules/{scheduleId}
```

Send only the fields you want to change; the fields are the same as [Create schedule](#create-schedule). `actions`, if sent, replaces the whole list, and the action limits and build check apply to the new list. `nextRunAt` is recalculated.

```bash
curl -X PUT https://api.scrape.ly/v2/schedules/9d4b2f6e-7a1c-4e3b-8f5d-2c0a1b9e7d3f \
  -H "Authorization: Bearer $SCRAPELY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "isEnabled": false,
    "cronExpression": "0 7 * * *"
  }'
```

Returns the updated schedule.

## Delete schedule

```
DELETE /v2/schedules/{scheduleId}
```

Returns `204 No Content`.

```bash
curl -X DELETE -H "Authorization: Bearer $SCRAPELY_TOKEN" \
  https://api.scrape.ly/v2/schedules/9d4b2f6e-7a1c-4e3b-8f5d-2c0a1b9e7d3f
```

## Get schedule log

```
GET /v2/schedules/{scheduleId}/log
```

Returns JSON `{ "data": [...] }`, not plain text. Each entry is one firing of the schedule, oldest first. The newest 30 firings are kept.

| Field | Type | Description |
| --- | --- | --- |
| `message` | string | What happened when the schedule fired. |
| `level` | string | `INFO` or `ERROR`. |
| `createdAt` | string | ISO 8601 timestamp. |

Messages take these forms:

| Message | Level |
| --- | --- |
| `Successfully started N run(s): <runId>, <runId>` | `INFO` |
| `Started N run(s), M error(s): <details>` | `ERROR` |
| `Skipped: the previous run is still going (exclusive).` | `INFO` |
| `Skipped a firing missed during an outage (due <time>).` | `INFO` |

```bash [Request]
curl -H "Authorization: Bearer $SCRAPELY_TOKEN" \
  https://api.scrape.ly/v2/schedules/9d4b2f6e-7a1c-4e3b-8f5d-2c0a1b9e7d3f/log
```

```json [Response]
{
  "data": [
    {
      "message": "Successfully started 1 run(s): a3f9c1e7b5d20486f",
      "level": "INFO",
      "createdAt": "2026-08-14T06:00:01.000Z"
    }
  ]
}
```

```api-test
{ "method": "GET", "path": "/v2/schedules/9d4b2f6e-7a1c-4e3b-8f5d-2c0a1b9e7d3f/log" }
```

---

- Previous: [Webhooks](https://scrape.ly/docs/api-webhooks.md)
- Next: [Users](https://scrape.ly/docs/api-users.md)
- All docs: [Docs index](https://scrape.ly/docs.md), [llms.txt](https://scrape.ly/llms.txt)
