---
title: "Automations · Scrape.ly Docs"
description: "Create and run automations: triggers, steps, filters, the AI step, deliveries, webhooks listed as automations, run history, and AI step costs."
url: https://scrape.ly/docs/api-automations
markdown: https://scrape.ly/docs/api-automations.md
---

# Automations

> Create and run automations: triggers, steps, filters, the AI step, deliveries, webhooks listed as automations, run history, and AI step costs.

An automation is one trigger, 1 to 10 run steps, optional filters, an optional AI step, and up to 10 deliveries. See [Automations](https://scrape.ly/docs/automations.md) for what each part does in the dashboard. All endpoints are prefixed with `https://api.scrape.ly/v2`. Authenticate with `Authorization: Bearer $SCRAPELY_TOKEN`.

Automations and schedules are the same objects. Every [schedule](https://scrape.ly/docs/api-schedules.md) is an automation with a schedule trigger and nothing else, so it is listed here too, and an automation with a schedule trigger is also listed under `/v2/schedules`.

[Webhooks](https://scrape.ly/docs/api-webhooks.md) are automations too: a run event and one action (an HTTP request, a Slack, Discord, or email message, a Google Sheets export, or a run start). They stay in `/v2/webhooks`, which works exactly as before, and `GET /v2/automations?include=webhooks` lists them next to the others. See [Webhooks as automations](#webhooks-as-automations).

Any API key with the right scopes can manage automations, including the AI step: `automations:read` to read, and `automations:write` with `actors:run` and the run permission `actor-default` to change or run them. Keys limited to specific Actors can't create or change automations. See [API keys & permissions](https://scrape.ly/docs/api-keys.md#scopes). Actor run tokens get `403 insufficient-permissions` on every write.

## Endpoints

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v2/automations` | List your automations |
| `POST` | `/v2/automations` | Create an automation |
| `GET` | `/v2/automations/{automationId}` | Get an automation |
| `PUT` | `/v2/automations/{automationId}` | Update an automation (only the parts you send) |
| `DELETE` | `/v2/automations/{automationId}` | Delete an automation and its run history |
| `POST` | `/v2/automations/{automationId}/duplicate` | Copy an automation |
| `POST` | `/v2/automations/{automationId}/runs` | Run it now |
| `GET` | `/v2/automations/{automationId}/runs` | Run history, newest first |
| `GET` | `/v2/automations/{automationId}/runs/{runId}` | One run |
| `GET` | `/v2/users/me/usage/automations` | AI step costs per automation run |
| `POST` | `/v2/webhooks/{webhookId}/convert-to-automation` | Turn a Trigger Actor or Trigger task webhook into an automation. See [Convert a webhook](#convert-a-webhook). |

## Errors and limits

| Status | `error.type` | When |
| --- | --- | --- |
| `400` | `invalid-input` | A part of the body is not valid. The message names the field, for example `steps[0].actorId is required for an actor step` or `deliveries[1].content "ai" needs an AI step; use "digest" or add ai`. |
| `400` | `not-connected` | A `slack` delivery while no Slack workspace is connected. |
| `400` | `actor-not-built` | A step runs one of your Actors that has no successful build yet. |
| `400` | `actor-not-available` | A step runs someone else's Actor that can't be run right now. |
| `403` | `feature-not-enabled` | Automations are not enabled for your account yet. |
| `403` | `insufficient-permissions` | The key's scopes don't cover the request, or a run token tried to write. |
| `403` | `limit-reached` | Creating an automation when you already have your plan's maximum. Automations and schedules share one limit. |
| `404` | `automation-not-found` | The automation does not exist or is not yours. Also for a `runAutomation` delivery that names someone else's automation. |
| `404` | `automation-run-not-found` | The run does not exist or belongs to another automation. |
| `404` | `record-or-token-not-found` | A step or trigger names an Actor that doesn't exist. |
| `404` | `saved-input-not-found` | A run-finished trigger names a saved input you don't own. |
| `409` | `automation-busy` | **Run now** while the automation is starting a run, or while its previous run is still going and **Skip if the previous run is still going** is on. |

| Limit | Value |
| --- | --- |
| Steps | 1 to 10 (at most 10 Actor steps and 10 saved input steps, as for schedules) |
| Filter conditions | 10 |
| Key fields for "only new items" | 1 to 5 |
| AI prompt | 2,000 characters |
| Deliveries | 10 |
| Step input | 500 KB of JSON |
| Run history | The last 30 runs per automation |
| Chains | An automation started by another automation (a run-finished trigger or a `runAutomation` delivery) can start another one, at most 3 automations deep |

## The automation object

```json
{
  "id": "593d12cc-c97c-4ef0-bd5d-a49512326805",
  "name": "daily-digest",
  "title": "Daily digest",
  "description": null,
  "trigger": { "kind": "schedule", "cron": "0 7 * * *", "timezone": "Europe/Zurich" },
  "steps": [
    {
      "id": "b62821cb7",
      "kind": "savedInput",
      "savedInputId": "9f2f54be-7f12-4efb-92a5-9310baa3a527",
      "savedInputName": "link-preview-tech-sites",
      "savedInputTitle": "Tech sites preview",
      "actorId": "bf064b360dd844999",
      "actorName": "link-preview-scraper",
      "inputOverride": { "maxItems": 2 },
      "runOptions": { "memoryMbytes": 512, "maxTotalChargeUsd": 0.05 }
    },
    {
      "id": "26658f76a",
      "kind": "actor",
      "actorId": "8c2684237c614269b",
      "actorName": "remoteok-scraper",
      "input": { "searchQueries": ["engineer"], "maxResults": 3 },
      "runOptions": { "maxItems": 3 }
    }
  ],
  "filters": null,
  "ai": { "mode": "summarize", "prompt": "Two short bullet points: what the pages are and which remote jobs were found." },
  "deliveries": [
    { "id": "cc8b00d2b34a", "channel": "dashboard", "content": "both", "target": null }
  ],
  "options": {
    "isEnabled": true,
    "isExclusive": true,
    "notifyOnFailure": true,
    "maxWaitMinutes": 20,
    "maxItemsPerStep": null
  },
  "nextRunAt": "2026-10-21T05:00:00.000Z",
  "lastRunAt": null,
  "createdAt": "2026-10-20T01:27:10.000Z",
  "modifiedAt": "2026-10-20T04:44:07.000Z",
  "lastRun": {
    "id": "3c246aa2b45548c8b1be",
    "status": "succeeded",
    "triggerKind": "manual",
    "startedAt": "2026-10-20T01:27:10.733Z",
    "finishedAt": "2026-10-20T01:28:02.853Z",
    "aiCostUsd": 0.010071
  }
}
```

| Field | Description |
| --- | --- |
| `source` | `automation`, or `webhook` for a [webhook listed as an automation](#webhooks-as-automations). |
| `name` | API name: letters, digits, and dashes, up to 63 characters, unique among your automations and schedules. Generated from `title` when you leave it out. |
| `title`, `description` | Display title (up to 120 characters) and description (up to 2,000). |
| `trigger` | What starts it. See [Triggers](#triggers). |
| `steps` | The runs it starts, all at the same time. See [Steps](#steps). |
| `filters`, `ai`, `deliveries`, `options` | See [Filters](#filters), [AI step](#ai-step), [Deliveries](#deliveries), and [Options](#options). |
| `nextRunAt` | The next firing of a schedule trigger, or `null` (paused, manual, or run-finished). |
| `lastRunAt` | The last firing of the schedule trigger. Runs started with **Run now** or by another trigger don't change it; use `lastRun`. |
| `lastRun` | The newest run, with its status and AI cost, or `null`. |

Delivery secrets (webhook and Discord URLs) are encrypted when saved and never returned: responses show a masked `target` such as `https://webhook.site/544f…4c2f`.

### Triggers

| `trigger` | Starts the automation |
| --- | --- |
| `{ "kind": "manual" }` | Only when you run it: **Run now**, `POST …/runs`, a `runAutomation` delivery, or the AI chat. |
| `{ "kind": "schedule", "cron": "0 9 * * 1", "timezone": "Europe/Berlin" }` | On a cron expression, evaluated in `timezone` (an IANA name, default `UTC`). The rules are those of [schedules](https://scrape.ly/docs/api-schedules.md#cron-expressions). `cronExpression` is accepted for `cron`. |
| `{ "kind": "runFinished", "actorId": "…", "statuses": ["SUCCEEDED"] }` | When one of your runs of that Actor finishes with one of the `statuses`. |
| `{ "kind": "runFinished", "savedInputId": "…", "statuses": ["SUCCEEDED", "FAILED"] }` | When a run of that saved input finishes. |

Webhooks listed as automations have a fourth, read-only kind, `runEvent`; see [Webhooks as automations](#webhooks-as-automations).

A run-finished trigger needs exactly one of `actorId` (your own Actor, or a public one you run) or `savedInputId` (one of your saved inputs). `statuses` are `SUCCEEDED`, `FAILED`, `ABORTED`, and `TIMED-OUT` (`TIMED_OUT` is accepted); the default is `["SUCCEEDED"]`. Each finished run starts the automation at most once, and an automation is never started by a run it started itself.

### Steps

| Step | Fields |
| --- | --- |
| Saved input | `kind: "savedInput"`, `savedInputId`, optional `inputOverride` and `runOptions`. |
| Actor | `kind: "actor"`, `actorId` (ID or `username/actor-name`), optional `input` and `runOptions`. |

`kind` can be left out: a step with `savedInputId` is a saved input step, any other an Actor step.

- A saved input step runs the [saved input](https://scrape.ly/docs/run-actors.md#saved-inputs) as it is when the automation fires, so editing the saved input changes what the automation runs. `inputOverride` is merged over the saved input one level deep: the fields you list replace the saved ones, the others stay as saved.
- An Actor step without `input` runs with your default saved input for that Actor, or the input schema's defaults.
- `runOptions` takes `build` (your own Actors only), `timeoutSecs`, `memoryMbytes`, `maxItems`, `maxTotalChargeUsd`, and `restartOnError`. Missing options come from the saved input, then from the Actor's defaults. There is no default spending cap: a step's run gets `maxTotalChargeUsd` only from the step, the saved input, or the Actor's own minimum cap. When the input leaves the Actor's own `maxItems` field empty and its input schema has one, `maxItems` fills it too, within the schema's minimum and maximum.

Responses add `id`, `actorName`, and for saved input steps `savedInputName` and `savedInputTitle`.

**Input from the finished run.** With a `runFinished` trigger, text values in `input` and `inputOverride` can use the variables of [webhook payload templates](https://scrape.ly/docs/integrations.md#payload-and-header-templates), filled from the run that triggered the automation: `{{resource}}` (that run, with dotted paths such as `{{resource.defaultDatasetId}}`), `{{eventData.…}}`, `{{eventType}}`, `{{userId}}`, `{{createdAt}}`, and the shortcuts such as `{{actorRunId}}` and `{{defaultDatasetId}}`. A string that is just one variable (`"{{resource.defaultDatasetId}}"`) becomes that value, which may be a number or an object; a variable inside other text is inserted as text; unknown names are left as written. The values are filled in once per firing. With `manual` and `schedule` triggers the input is used as written.

```json
{
  "trigger": { "kind": "runFinished", "actorId": "<crawlerActorId>", "statuses": ["SUCCEEDED"] },
  "steps": [
    { "kind": "actor", "actorId": "<enricherActorId>", "input": { "datasetId": "{{resource.defaultDatasetId}}", "note": "From run {{actorRunId}}" } }
  ]
}
```

Every step run is a normal run on your account with the origin `SCHEDULER`, billed like any other run. It fires the run's [webhooks](https://scrape.ly/docs/api-webhooks.md), including `ACTOR.RUN.CREATED`.

### Filters

```json
{
  "onlyWithResults": true,
  "onlyNewItems": { "keyFields": ["url"] },
  "conditions": [
    { "field": "price.amount", "op": "LESS_THAN", "value": 100 },
    { "field": "title", "op": "CONTAINS", "value": "remote" }
  ]
}
```

Filters work on the items the steps returned, after all steps finished.

- `conditions`: an item is kept only when every condition holds. `field` is a top-level or dotted field name. `op` is one of `EQUALS`, `NOT_EQUALS`, `GREATER_THAN`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN`, `LESS_THAN_OR_EQUAL`, `CONTAINS`, `NOT_CONTAINS`, `IS_SET`, and `IS_NOT_SET` (no `value` for the last two). Comparisons need a number `value` and read numbers from text such as `"€1,299.00"`. Text comparisons ignore case, and `CONTAINS` also searches arrays.
- `onlyNewItems`: keeps only items whose `keyFields` values were not in the previous run. On the first run every item counts as new. Only a short hash of the key values is stored, for the last run only (up to 10,000 items).
- `onlyWithResults`: when no item is left, nothing is delivered and the AI step doesn't run. `onlyNewItems` does the same when there is nothing new.

### AI step

```json
{ "mode": "summarize", "prompt": "List the five cheapest offers with links." }
```

| `mode` | What the AI step does with the filtered items |
| --- | --- |
| `summarize` | Writes a short Markdown summary following your prompt. |
| `filter` | Decides from your prompt whether to deliver at all. A "no" skips the deliveries; an unclear answer delivers anyway. |
| `compare` | Compares the items with the previous successful run and leads with what changed. |
| `extract` | Returns a JSON array with the fields your prompt asks for, at most 100 objects. |

`prompt` is required, up to 2,000 characters. The step reads up to `maxItemsPerStep` items per step (200 by default), shortened to fit its input budget.

> [!IMPORTANT]
> The AI step is billed to your account like the AI chat. Each run stores its cost in `aiCostUsd`, and your usage shows it as its own line, **Automation AI step**. See [AI step costs](#ai-step-costs). Without enough credits the AI step is skipped (`aiNote: "AI step skipped: not enough credits"`) and the deliveries send the results digest instead.

### Deliveries

Each delivery has a `channel`, a `content`, and the fields of its channel:

| `channel` | Fields | Sends |
| --- | --- | --- |
| `dashboard` | none | A dashboard notification that opens the run. |
| `email` | `email` (optional; default your account email), `columns` (optional) | An email. Any address other than your account email gets a confirmation link first, and the response lists it under `verifications`. `columns` picks the results table of each step: `[{ "step": 0, "title": "name", "fields": ["price", "address.city"] }]`, where `step` is the step's 0-based position, `title` the field shown as the row title (`null` picks it automatically), and `fields` up to 4 value columns. Steps not listed, and `columns: null`, pick their columns automatically. `GET /v2/automations/email-fields?actorId=…` (or `savedInputId=…`) lists the fields of your latest successful run to choose from. |
| `slack` | none | A message to the channel connected under **Settings**. Needs a connected Slack workspace. |
| `discord` | `discordWebhookUrl` (`https://discord.com/api/webhooks/…`), or `webhookId` of one of your Discord webhooks (a webhook with a `discord` notification) | A Discord message. |
| `webhook` | `url` (http or https, public addresses only) | A signed `POST` with the run and its items. See [Webhook delivery](#webhook-delivery). |
| `googleSheets` | `sheetUrl`, optional `sheetName` (first tab if empty) and `maxRows` (1 to 10,000, default 1,000) | Appends the kept items as rows. Share the sheet with the address from `GET /v2/integrations/gsheets` first. |
| `runAutomation` | `automationId` (another of your automations) | Starts that automation. It must be enabled; an automation can't run itself. |

`content` is `digest` (the results digest: counts per step, links to the runs and datasets, and the first 5 items), `ai` (the AI step's text), or `both`. The default is `both` with an AI step and `digest` without one. `ai` and `both` need an AI step; `googleSheets` and `runAutomation` always use `digest`. When the AI step has no text, `ai` and `both` send the digest.

Email, Slack, Discord, and Google Sheets deliveries share the confirmation, unsubscribe, and rate-limit rules of [webhook notifications](https://scrape.ly/docs/integrations.md#slack-discord-and-email).

On update, send a delivery back with its `id` and without a new `url` or `discordWebhookUrl` to keep the stored secret.

#### Webhook delivery

The request body:

```json
{
  "userId": "uSrIdExAmPlE123",
  "createdAt": "2026-10-20T07:01:12.000Z",
  "eventType": "AUTOMATION.RUN.SUCCEEDED",
  "eventData": {
    "automationId": "593d12cc-c97c-4ef0-bd5d-a49512326805",
    "automationRunId": "3c246aa2b45548c8b1be",
    "status": "SUCCEEDED",
    "startedAt": "2026-10-20T07:00:00.000Z",
    "actorRunIds": ["ece86a572f094bef8", "d5afaf264d5a443cb"]
  },
  "resource": {
    "id": "3c246aa2b45548c8b1be",
    "automationId": "593d12cc-c97c-4ef0-bd5d-a49512326805",
    "triggerKind": "schedule",
    "status": "succeeded",
    "steps": [ { "index": 0, "kind": "savedInput", "runId": "ece86a572f094bef8", "status": "SUCCEEDED", "itemCount": 2, "datasetId": "…" } ],
    "filter": { "totalItems": 5, "keptItems": 5, "newItems": null, "droppedByConditions": 0, "deliver": true, "reason": null },
    "aiOutput": "…",
    "aiCostUsd": 0.010071,
    "automation": { "id": "593d12cc-c97c-4ef0-bd5d-a49512326805", "name": "Daily digest" },
    "message": "Markdown of the delivery's content setting",
    "items": [ { "...": "up to 100 kept items" } ],
    "url": "https://my.scrape.ly/automations/593d12cc-c97c-4ef0-bd5d-a49512326805/runs/3c246aa2b45548c8b1be"
  }
}
```

`eventType` is `AUTOMATION.RUN.FAILED` (with `status: "FAILED"`) when every step failed without results. `resource` is the automation run as [Get run](#get-a-run) returns it, plus `automation`, `message`, `items`, and `url`.

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `User-Agent` | `Scrapely-Webhook/1.0` |
| `X-Scrapely-Webhook` | HMAC-SHA256 of the body, signed with a platform key, as for [webhooks](https://scrape.ly/docs/api-webhooks.md#request). |
| `X-Scrapely-Webhook-Dispatch-Id` | ID of the dispatch record. |
| `X-Scrapely-Request-Origin` | `AUTOMATION` |

The request also carries the reference platform's `X-Apify-Webhook`, `X-Apify-Webhook-Dispatch-Id`, and `X-Apify-Request-Origin` headers with the same values (origin `WEBHOOK`), so receivers written for that platform work unchanged.

You can't verify the signature, because the signing key is not available to you. To authenticate deliveries, put a secret in the URL's query string, for example `https://example.com/hook?token=<long random string>`, and check it in your endpoint. The URL is stored encrypted and only shown masked.

A delivery succeeds on any `2xx`. Network errors, `429`, and `5xx` are tried 3 times in all, 1 and 2 seconds apart; other responses are not retried. Each attempt times out after 30 seconds, and redirects are not followed. Every delivery is listed in [`GET /v2/webhook-dispatches`](https://scrape.ly/docs/api-webhooks.md#list-webhook-dispatches) with the automation ID as `webhookId`.

### Options

| Field | Default | Description |
| --- | --- | --- |
| `isEnabled` | `true` | A paused automation doesn't fire on its trigger. **Run now** still works. |
| `isExclusive` | `true` with an AI step or deliveries, else `false` | Skip a firing while the previous run, or any of its step runs, is still going. The skipped firing shows in the run history. |
| `notifyOnFailure` | `true` | The automation's failure alert. See [Notifications](https://scrape.ly/docs/notifications.md#schedules-and-automations). Same setting as `notificationsEnabled` in the [Schedules API](https://scrape.ly/docs/api-schedules.md). |
| `maxWaitMinutes` | `120` | How long a run waits for its step runs (1 to 1,440) before it filters and delivers what it has. |
| `maxItemsPerStep` | `200` | Items read per step for filters, the digest, the AI step, and deliveries (1 to 1,000). |

Send `null` for `maxWaitMinutes` or `maxItemsPerStep` to go back to the default.

## List automations

```
GET /v2/automations
```

| Query param | Type | Description |
| --- | --- | --- |
| `include` | string | `webhooks` also lists your [webhooks as automations](#webhooks-as-automations) (not ad-hoc ones). Without it, only automations are listed. |
| `actorId` | string | Only automations with a step or a run-finished trigger that uses this Actor (ID or `username~name`). With `include=webhooks`, also webhooks whose condition names this Actor or one of its saved inputs, or that start it or one of its saved inputs. |
| `savedInputId` | string | Only automations with a step or trigger that uses this saved input. With `include=webhooks`, also webhooks whose condition names it or that start it. |
| `search` | string | Text in the API `name` (case-insensitive); for webhooks, in the `description`. |
| `sortBy` | string | `createdAt` (default), `name`, `isEnabled`, `lastRunAt`, or `nextRunAt`. |
| `desc` | boolean | **Default is `true`** (newest first). Pass `desc=0` for ascending. |
| `offset` / `limit` | number | [Pagination](https://scrape.ly/docs/api-overview.md#pagination). Default `limit` is `100`, max `1000`. |

Every automation is listed, plain schedules included. With `include=webhooks`, automations and webhooks are merged and sorted by `createdAt`, and `total` counts both. Returns `{ "data": { "total", "count", "offset", "limit", "desc", "items": [ ...automation ] } }`.

```bash
curl -H "Authorization: Bearer $SCRAPELY_TOKEN" \
  "https://api.scrape.ly/v2/automations?actorId=calmswan70~link-preview-scraper&limit=20"
```

```api-test
{ "method": "GET", "path": "/v2/automations?limit=5" }
```

## Create automation

```
POST /v2/automations
```

`trigger` and `steps` are required; everything else is optional. Returns `201` with `{ "data": { ...automation } }`, plus `verifications` when an email delivery sent a confirmation link.

A daily summary of two Actors, sent by email and to a webhook:

```bash
curl -X POST https://api.scrape.ly/v2/automations \
  -H "Authorization: Bearer $SCRAPELY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Morning jobs digest",
    "trigger": { "kind": "schedule", "cron": "0 7 * * 1-5", "timezone": "Europe/Berlin" },
    "steps": [
      { "kind": "savedInput", "savedInputId": "<savedInputId>", "inputOverride": { "maxItems": 20 } },
      { "kind": "actor", "actorId": "<username>/<actor-name>", "input": { "searchQueries": ["engineer"], "maxResults": 20 } }
    ],
    "filters": { "onlyNewItems": { "keyFields": ["url"] } },
    "ai": { "mode": "summarize", "prompt": "The ten most interesting new jobs, one line each, with links." },
    "deliveries": [
      { "channel": "email" },
      { "channel": "webhook", "content": "digest", "url": "https://example.com/hooks/jobs?token=<secret>" }
    ]
  }'
```

Run another saved input whenever a run of one succeeds:

```json
{
  "title": "Enrich after each crawl",
  "trigger": { "kind": "runFinished", "savedInputId": "<crawlSavedInputId>", "statuses": ["SUCCEEDED"] },
  "steps": [ { "savedInputId": "<enrichSavedInputId>" } ]
}
```

## Get automation

```
GET /v2/automations/{automationId}
```

Returns `{ "data": { ...automation } }`.

```api-test
{ "method": "GET", "path": "/v2/automations/{automationId}" }
```

## Update automation

```
PUT /v2/automations/{automationId}
```

Send only the parts you want to change: `name`, `title`, `description`, `trigger`, `steps`, `filters`, `ai`, `deliveries`, or `options`. A part you send replaces that part; `options` fields you leave out keep their values. Send `"ai": null` to remove the AI step; deliveries that sent its text then send the digest.

```bash
curl -X PUT https://api.scrape.ly/v2/automations/593d12cc-c97c-4ef0-bd5d-a49512326805 \
  -H "Authorization: Bearer $SCRAPELY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "options": { "isEnabled": false } }'
```

Returns the updated automation.

## Delete automation

```
DELETE /v2/automations/{automationId}
```

Deletes the automation, its run history, and its "only new items" state. Returns `204 No Content`. Runs it already started are not affected.

## Duplicate automation

```
POST /v2/automations/{automationId}/duplicate
```

Creates a copy with the same trigger, steps, filters, AI step, deliveries, and options. Stored webhook and Discord URLs are copied on the server. The copy is **paused** unless you send `"isEnabled": true`, so a scheduled automation doesn't fire twice. Optional body fields: `title` (default: the original title with ` (copy)`), `name`, and `isEnabled`. Returns `201` with the copy.

## Run now

```
POST /v2/automations/{automationId}/runs
```

Starts a run now, whatever the trigger, also while the automation is paused. Returns `201` with the [run](#get-a-run): its steps have started, and the filters, AI step, and deliveries follow once they finish. Returns `409 automation-busy` while another start of the same automation is in progress, or, with `isExclusive`, while the previous run is still going.

## List runs

```
GET /v2/automations/{automationId}/runs
```

The run history, newest first: the last 30 runs. `offset` and `limit` (default `20`, max `100`) page through it. Returns `{ "data": { "total", "count", "offset", "limit", "items": [ ...run ] } }`.

```api-test
{ "method": "GET", "path": "/v2/automations/{automationId}/runs?limit=5" }
```

## Get a run

```
GET /v2/automations/{automationId}/runs/{runId}
```

```json
{
  "data": {
    "id": "3c246aa2b45548c8b1be",
    "automationId": "593d12cc-c97c-4ef0-bd5d-a49512326805",
    "triggerKind": "manual",
    "status": "succeeded",
    "steps": [
      {
        "index": 0, "kind": "savedInput", "actorId": "bf064b360dd844999", "taskId": "9f2f54be-7f12-4efb-92a5-9310baa3a527",
        "runId": "ece86a572f094bef8", "status": "SUCCEEDED", "itemCount": 2, "datasetId": "185ec2dd-ff21-4613-9590-ae36f1ce8c1d",
        "error": null, "actorName": "link-preview-scraper", "savedInputName": "link-preview-tech-sites", "savedInputTitle": "Tech sites preview"
      }
    ],
    "filter": { "totalItems": 5, "keptItems": 5, "newItems": null, "droppedByConditions": 0, "deliver": true, "reason": null },
    "aiOutput": "**Both steps succeeded: …**",
    "aiCostUsd": 0.010071,
    "aiStatus": "succeeded",
    "aiNote": null,
    "deliveries": [
      { "id": "cc8b00d2b34a", "channel": "dashboard", "status": "sent", "error": null, "sentAt": "2026-10-20T01:28:02.853Z", "dispatchId": null }
    ],
    "error": null,
    "chainDepth": 0,
    "startedAt": "2026-10-20T01:27:10.733Z",
    "finishedAt": "2026-10-20T01:28:02.853Z",
    "automation": { "id": "593d12cc-c97c-4ef0-bd5d-a49512326805", "name": "daily-digest", "title": "Daily digest" }
  }
}
```

| Field | Values |
| --- | --- |
| `status` | `running` (steps going), `processing` (filters and AI step), `delivering`, `succeeded`, `failed` (every step failed without results), or `skipped` (a firing that didn't run; `error` says why). |
| `triggerKind` | `schedule`, `manual`, `runFinished`, `chain` (started by another automation), or `migrated`. |
| `steps[].status` | The step run's status, `PENDING` while it waits for a free run slot, or `FAILED` when it couldn't start (`error` says why). |
| `filter` | Items read, kept, new, and dropped by conditions, whether anything was delivered, and why not. |
| `aiStatus`, `aiNote` | `succeeded`, `skipped`, or `failed`, with a note such as `AI step skipped: not enough credits`. |
| `aiCostUsd` | What the AI step cost, or `null` without one. |
| `deliveries[]` | Each delivery's `status` (`sent`, `failed`, or `skipped`) and `error`. `dispatchId` links webhook deliveries to their dispatch record. |

## AI step costs

```
GET /v2/users/me/usage/automations
```

Lists the AI step charges of a period, newest first (up to 1,000), with the automation run and automation each one belongs to. This is what the **Automation AI steps** section of **Billing → Usage** shows. Needs `billing:read` on a scoped key.

| Query param | Description |
| --- | --- |
| `from` | ISO date or time, inclusive. Use it for a billing period that doesn't start on the 1st. |
| `to` | ISO date or time, exclusive (default now). Needs `from`. At most 366 days after `from`. |
| `month` | `YYYY-MM`, used when `from` is not set. Default: the current month (UTC). |

```bash [Request]
curl -H "Authorization: Bearer $SCRAPELY_TOKEN" \
  "https://api.scrape.ly/v2/users/me/usage/automations?from=2026-10-12T00:00:00Z"
```

```json [Response]
{
  "data": {
    "month": null,
    "from": "2026-10-12T00:00:00.000Z",
    "to": "2026-10-20T08:00:00.000Z",
    "totalUsd": 0.041139,
    "count": 15,
    "items": [
      {
        "label": "Automation AI step",
        "automationRunId": "c2d61024857e45fcbfd4",
        "automationId": "9b85a0bb-0e69-4da1-95c1-bd5523f6093f",
        "automationName": "Price watch",
        "costUsd": 0.002202,
        "createdAt": "2026-10-20T04:36:01.000Z"
      }
    ]
  }
}
```

`month` is `null` when you pass `from`. A bad date, `to` without `from`, or `to` not after `from` returns `400 invalid-input`.

```api-test
{ "method": "GET", "path": "/v2/users/me/usage/automations" }
```

## Webhooks as automations

Every webhook of your account, except ad-hoc ones, is also an automation with `"source": "webhook"`. It has the webhook's ID, so `GET /v2/automations/{webhookId}` returns it. It is created and edited with the [Webhooks API](https://scrape.ly/docs/api-webhooks.md), which stays unchanged: its dispatches, retries, signatures, and integration tokens behave as before.

```json
{
  "id": "HG7ML7M8z78YcAPEB",
  "source": "webhook",
  "name": null,
  "title": "Slack when the crawl fails",
  "description": null,
  "trigger": { "kind": "runEvent", "eventTypes": ["ACTOR.RUN.FAILED", "ACTOR.RUN.TIMED_OUT"], "actorId": "bf064b360dd844999", "savedInputId": null, "runId": null },
  "steps": [],
  "filters": null,
  "ai": null,
  "deliveries": [
    { "id": "webhook", "channel": "slack", "content": "event", "target": "#alerts · Acme", "message": null, "previewItems": 3, "onlyWithResults": false, "connected": true }
  ],
  "options": { "isEnabled": true, "isExclusive": false, "notifyOnFailure": false, "maxWaitMinutes": null, "maxItemsPerStep": null },
  "nextRunAt": null,
  "lastRunAt": "2026-10-21T08:12:40.000Z",
  "adminDisabled": null,
  "createdAt": "2026-10-20T10:00:00.000Z",
  "modifiedAt": "2026-10-20T10:00:00.000Z",
  "lastRun": { "id": null, "status": "succeeded", "triggerKind": "runEvent", "startedAt": "2026-10-21T08:12:40.000Z", "finishedAt": "2026-10-21T08:12:40.000Z", "aiCostUsd": null },
  "webhook": { "...": "the webhook as GET /v2/webhooks/{webhookId} returns it" }
}
```

- `title` is the webhook's `description`, or a generated title when it has none.
- `trigger` is `{ "kind": "runEvent", "eventTypes", "actorId", "savedInputId", "runId" }`: the webhook's `eventTypes` and its `condition` (`actorId`, `actorTaskId`, `actorRunId`). All three `null` means every run (and build) of your account.
- `filters` is `{ "onlyWithResults": true }` for a notification with **Only for runs with results**, else `null`.
- `deliveries` holds exactly one action, with `id` `"webhook"` and `content` `"event"`:

| `channel` | When | Fields |
| --- | --- | --- |
| `webhook` | An HTTP webhook | `url`, `target` (the URL), `payloadTemplate`, `headersTemplate`, `shouldInterpolateStrings`, `ignoreSslErrors`, `doNotRetry` |
| `slack`, `discord`, `email` | A webhook with that `notification` | `target`, `message`, `previewItems`, `onlyWithResults`; `connected` for Slack, `emailState` for email |
| `googleSheets` | A `gsheets` notification | `target` (the sheet link), `sheetName`, `mode` (`append` or `replace`), `maxRows` |
| `startRun` | A webhook that calls `/v2/actors/{actorId}/runs` or `/v2/actor-tasks/{taskId}/runs` on this API | `target`, `actorId` or `savedInputId`, `inputTemplate` (the payload template), `runOptions` (from the URL's query) |

Header values are masked as in `/v2/webhooks` for keys that can't see them.

On a webhook's ID:

| Request | Does |
| --- | --- |
| `PUT /v2/automations/{webhookId}` | Changes `title` (the webhook's `description`) and `options.isEnabled`. Anything else answers `400 invalid-input`; edit it with `PUT /v2/webhooks/{webhookId}`. |
| `DELETE /v2/automations/{webhookId}` | The same as `DELETE /v2/webhooks/{webhookId}`: deletes the webhook and its dispatches and revokes its integration token. `204`. |
| `POST /v2/automations/{webhookId}/runs` | Sends a test, as `POST /v2/webhooks/{webhookId}/test`. `201` with the test as a run. |
| `GET /v2/automations/{webhookId}/runs[/{runId}]` | The webhook's dispatches as runs, newest first (see below). |
| `POST /v2/automations/{webhookId}/duplicate` | Not available: `400`. |

A dispatch as a run:

```json
{
  "id": "4f1c2a9e-…",
  "automationId": "HG7ML7M8z78YcAPEB",
  "triggerKind": "runEvent",
  "status": "succeeded",
  "eventType": "ACTOR.RUN.FAILED",
  "actorRunId": "ece86a572f094bef8",
  "actorId": "bf064b360dd844999",
  "steps": [],
  "filter": null,
  "aiOutput": null, "aiCostUsd": null, "aiStatus": null, "aiNote": null,
  "deliveries": [
    { "id": "webhook", "channel": "slack", "status": "sent", "error": null, "sentAt": "2026-10-21T08:12:40.000Z", "dispatchId": "4f1c2a9e-…", "statusCode": 200, "responseTimeMs": 412 }
  ],
  "error": null,
  "chainDepth": 0,
  "startedAt": "2026-10-21T08:12:40.000Z",
  "finishedAt": "2026-10-21T08:12:40.000Z",
  "automation": { "id": "HG7ML7M8z78YcAPEB", "name": null, "title": "Slack when the crawl fails" }
}
```

## Convert a webhook

```
POST /v2/webhooks/{webhookId}/convert-to-automation
POST /v2/webhooks/{webhookId}/convert-to-automation?dryRun=1
```

Turns a **Trigger Actor** or **Trigger task** webhook (one that calls `/v2/actors/{actorId}/runs` or `/v2/actor-tasks/{taskId}/runs` on this API when a run finishes) into an automation with a run-finished trigger and the same run as its step. The webhook is then deleted, which revokes its integration token. With `dryRun=1`, the response shows the automation that would be created and nothing changes.

- The webhook's condition must name an Actor or a task, and its events must all be run-finished events (`ACTOR.RUN.SUCCEEDED`, `FAILED`, `ABORTED`, `TIMED_OUT`).
- A JSON object payload becomes the step input. Variables inside its strings, such as `"{{resource.defaultDatasetId}}"`, carry over as [input from the finished run](#steps). A payload that isn't a JSON object (for example with a variable outside quotation marks), or one with variables and `shouldInterpolateStrings: false`, can't be converted. Without a payload, the step runs the saved input as saved, or the Actor with your default saved input.
- Run options in the URL (`build`, `timeout`, `memory`, `maxItems`, `maxTotalChargeUsd`, `restartOnError`) become the step's run options.
- Slack, Discord, Email, and Google Sheets webhooks and ad-hoc webhooks can't be converted. They are already listed as [automations](#webhooks-as-automations).

Returns `201` with `{ "data": { "automation", "webhookId", "webhookDeleted": true, "integrationTokenRevoked": true, "notes" } }`. A webhook that can't be converted without changing what it does returns `409 cannot-convert` with the reason. Needs `automations:write`, `actors:run`, and `webhooks:write` on a scoped key.

## Workflows (removed)

The AI workflows of the AI chat were replaced by automations. `/v2/workflows*` answers `410 endpoint-removed` with `Link: </v2/automations>; rel="successor-version"`. Use `/v2/automations`; an automation with an AI step and a delivery does what a workflow did.

---

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