---
title: "Actors & webhooks · Scrape.ly Docs"
description: "Call Actors, metamorph, add webhooks, charge events, and use API clients."
url: https://scrape.ly/docs/sdk-python-actors
markdown: https://scrape.ly/docs/sdk-python-actors.md
---

# Actors & webhooks

> Call Actors, metamorph, add webhooks, charge events, and use API clients.

An Actor can start other Actors and tasks, wait for them to finish, abort runs, hand its own run over to another Actor and register webhooks. Pay-per-event Actors can also charge for events. This page covers the `Actor` methods for all of that, and the two API clients the SDK gives you.

## Calling Actors and tasks

The run-control methods take an options object as their third argument. Import the option classes from `scrapely`:

```python
from scrapely import Actor, AbortOptions, CallOptions, StartOptions
```

### Start without waiting

```python
run = await Actor.start("owner~child-actor", {"url": url})
print(run.id, run.default_dataset_id)
```

`start(actor_id, run_input=None, options=None)` sends `POST /v2/actors/{actor_id}/runs` with `run_input` as the body. It returns an `ActorRun` as soon as the run is created. Pass `actor_id` as an ID or as `username~actor-name`. The SDK puts the value into the URL path without encoding it, so use `~` rather than `/`.

### Call and wait

```python
run = await Actor.call(
    "owner~child-actor",
    {"url": url},
    CallOptions(timeout=300, memory_mbytes=1024),
)
print(run.status, run.default_dataset_id)
```

`call(actor_id, run_input=None, options=None)` starts the run, then polls `GET /v2/actor-runs/{id}` every second until the status is `SUCCEEDED`, `FAILED`, `ABORTED` or `TIMED-OUT`. It returns the final `ActorRun`. A failed child run doesn't raise, so check `run.status` yourself.

If you set `timeout` and the child is still running when it expires, `call()` aborts the child and raises `TimeoutError`. If the token can't read the child run (HTTP 403), `call()` raises `RuntimeError`, and the child may still be running. That happens, for example, when the child was started with a different token.

### Call a task

```python
run = await Actor.call_task("task-id", {"url": url}, CallOptions(timeout=300))
```

`call_task(task_id, task_input=None, options=None)` sends `POST /v2/actor-tasks/{task_id}/runs`. The server merges `task_input` over the task's saved input. Then `call_task()` waits exactly like `call()`.

### Options

`CallOptions` and `StartOptions` have the same fields. All of them default to `None`:

| Field | Type | Meaning |
| --- | --- | --- |
| `token` | `str` | Use this API token instead of the run token |
| `build` | `str` | Build tag or number. Ignored on someone else's Actor, which always runs the [User Build](https://scrape.ly/docs/publishing.md#user-build). |
| `memory_mbytes` | `int` | Memory in MB |
| `timeout` | `int` or `"inherit"` | Run timeout in seconds. `"inherit"` uses this run's remaining time |
| `max_total_charge_usd` | `float` | Spending cap for a pay-per-event child |
| `restart_on_error` | `bool` | Restart the child if it fails |
| `force_permission_level` | `str` | Permission level for the child run, for example `"LIMITED_PERMISSIONS"` |
| `webhooks` | `list[Webhook]` | Ad-hoc webhooks for the child run |

The input always goes in the request body. The options are sent as query parameters, and the SDK base64-encodes the webhooks. `ActorPermissionLevel` contains `NONE`, `RUN`, `WRITE` and `ADMIN`, which don't match the platform's values. Pass `force_permission_level` as a string instead.

### Abort a run

```python
await Actor.abort("run-id", AbortOptions(status_message="Superseded", gracefully=True))
await Actor.abort()  # the current run
```

`abort(run_id=None, options=None)` defaults to the current run, and it raises `ValueError` if there's no run ID. With `status_message`, it first sends `PUT /v2/actor-runs/{id}` to set the message, then `POST /v2/actor-runs/{id}/abort`. The SDK sends `gracefully` only when you set it. `AbortOptions` has `token`, `status_message` and `gracefully`.

### Metamorph

```python
from scrapely import MetamorphOptions

await Actor.metamorph("owner~other-actor", {"url": url}, MetamorphOptions(build="latest"))
```

`metamorph(target_actor_id, run_input=None, options=None)` hands the current run over to another Actor. The run keeps its ID and default storages. The method sends `POST /v2/actor-runs/{runId}/metamorph` with `targetActorId`, `build` and `input`, shuts down and calls `sys.exit(0)`, so no code after the call runs. `MetamorphOptions` has only `build`, which applies only when you own the target Actor; someone else's Actor runs its [User Build](https://scrape.ly/docs/publishing.md#user-build).

The platform stores the previous input under `INPUT-METAMORPH-<n>` and the new input as `INPUT`. In the target, a Python `Actor.get_input()` currently returns the previous input. Read `await Actor.get_value("INPUT")` to get the input you passed. See [Input](https://scrape.ly/docs/sdk-python-input-output.md#actorgetinput).

Off the platform, `metamorph()` logs a warning and returns. Use metamorph when a pipeline should look like a single run. Use `call()` when you want a separate child run.

### Reboot

`await Actor.reboot()` restarts the run in a fresh container. It's meant for handling migrations. See [Events & state](https://scrape.ly/docs/sdk-python-events.md#actorreboot).

## Webhooks

`Actor.add_webhook()` creates an ad-hoc webhook that fires for the current run:

```python
from scrapely import Actor, Webhook, WebhookEventType

webhook = await Actor.add_webhook(Webhook(
    event_types=[WebhookEventType.ACTOR_RUN_SUCCEEDED, WebhookEventType.ACTOR_RUN_FAILED],
    request_url="https://example.com/scrapely-hook",
    idempotency_key="pipeline-run-123",
))
print(webhook.id)
```

The SDK sends `POST /v2/webhooks` with `isAdHoc: true` and `condition.actorRunId` set to the current run. It returns the webhook with `id` filled in. Off the platform, `add_webhook()` logs a warning and returns `None`.

| Field | Type | Required |
| --- | --- | --- |
| `event_types` | `list[WebhookEventType]` | Yes |
| `request_url` | `str` | Yes |
| `payload_template` | `str` | No |
| `headers_template` | `str` | No |
| `idempotency_key` | `str` | No |
| `do_not_retry` | `bool` | No |
| `ignore_ssl_errors` | `bool` | No |

`Webhook` raises `ValueError` when `event_types` or `request_url` is empty. Set `idempotency_key` so that a retried call doesn't register the webhook twice.

`WebhookEventType` contains `ACTOR_RUN_CREATED`, `ACTOR_RUN_SUCCEEDED`, `ACTOR_RUN_FAILED`, `ACTOR_RUN_ABORTED` and `ACTOR_RUN_TIMED_OUT` (values such as `ACTOR.RUN.SUCCEEDED`). Use the enum members. Plain strings work with `add_webhook()`, but they break `CallOptions.webhooks` and `StartOptions.webhooks`, which read `.value`.

To attach webhooks to a child run, pass `Webhook` objects in `CallOptions(webhooks=[...])` or `StartOptions(webhooks=[...])`. For webhooks that aren't tied to a run, see the [webhooks API](https://scrape.ly/docs/api-webhooks.md).

## Charging

Charging applies to Actors with pay-per-event pricing.

```python
result = await Actor.charge("page-processed", count=1)
if result.event_charge_limit_reached:
    await Actor.exit("Charge limit reached")
```

`charge(event_name, count=1)` returns a `ChargeResult` with these fields:

| Field | Meaning |
| --- | --- |
| `charged_count` | Events actually charged |
| `event_charge_limit_reached` | `True` when the run's budget can't cover another event of this type |
| `chargeable_within_limit` | Remaining chargeable count per event (`None` means unlimited) |

### How charges are recorded

At `init()`, the SDK reads the run's pricing and its existing charged counts. It reads them from the environment when the platform provides both, and otherwise from `GET /v2/actor-runs/{runId}`. Each billable charge is sent as `POST /v2/actor-runs/{runId}/charge` with a unique `Idempotency-Key`, and the SDK makes up to 3 attempts.

- **Budget.** When the run has a maximum charge, the SDK caps `count` at what the budget can cover. While the run is still under budget, it allows one extra event. When the cap is reached, `charged_count` is `0`.
- **Rejected charges.** If the platform rejects a charge with HTTP 400, 401, 402, 403, 404, 405 or 409, the SDK undoes it locally and returns `charged_count=0` with `event_charge_limit_reached=True`.
- **Unconfirmed charges.** The SDK re-sends a charge with the same key on `5xx` answers, timeouts and dropped connections, backing off from 0.5 s to 8 s for up to about a minute. If the platform still doesn't confirm it, `charge()` raises `ChargeUncertainError`, which has `operation_id`, `event_name` and `count`. The charge stays pending, and the SDK retries it before the next charge and at shutdown. It never charges twice for the same key. When 1024 charges are pending, the SDK refuses new ones.
- **Non-PPE Actors.** On an Actor without pay-per-event pricing, `charge()` logs one warning and returns `charged_count=0`.
- **Validation.** A `count` that isn't a positive integer raises `ValueError`. An event that isn't in the pricing is logged as unknown and isn't sent.
- **Shutdown.** Pending charges are flushed, within the shutdown time budget, when the Actor exits, on `SIGTERM` and before a metamorph or reboot.

Off the platform, the SDK never sends charges. For local budget testing, set `ACTOR_TEST_PAY_PER_EVENT=1`, so every event costs $1 against `ACTOR_MAX_TOTAL_CHARGE_USD`. Add `ACTOR_USE_CHARGING_LOG_DATASET=1` to log each charge to a `charging_log` dataset. Both variables raise `RuntimeError` on the platform.

### Charging with dataset writes

Use `Actor.push_data(items, charged_event_name="result-item")` to charge one event per item. It writes only the items the budget covers. See [Input & output](https://scrape.ly/docs/sdk-python-input-output.md#actorpushdata).

If the write fails after the charge was accepted and the SDK knows nothing was stored (the API returned an error, or the connection was never made), `push_data()` [voids the charge](https://scrape.ly/docs/api-runs.md#void-a-charge) and re-raises. A timeout after sending keeps the charge, since the items may have been stored. Voids that can't be confirmed are retried before the next push and at exit.

If the Actor's pricing defines the synthetic `apify-default-dataset-item` or `scrapely-default-dataset-item` event, the platform charges it for items in the default dataset. The SDK only tracks it locally, for budgeting. Don't charge these names yourself.

### Charging manager

`Actor.get_charging_manager()` returns the run's `ChargingManager`.

| Method | Returns | Purpose |
| --- | --- | --- |
| `get_pricing_info()` | `ActorPricingInfoResult` | `is_pay_per_event`, `pricing_model`, `per_event_prices`, `max_total_charge_usd` |
| `get_charged_event_count(event_name)` | `int` | Events charged so far |
| `get_charged_events()` | `dict[str, int]` | All charged counts |
| `get_max_total_charge_usd()` | `Decimal` | The spending cap, or `Decimal("Infinity")` |
| `get_total_charged_usd()` | `Decimal` | Total charged so far |
| `calculate_total_charged_amount()` | `Decimal` | Same total, recomputed |
| `calculate_max_event_charge_count_within_limit(event_name)` | `int` or `None` | Events of this type that still fit, or `None` for unlimited |
| `compute_chargeable()` | `dict[str, int \| None]` | Remaining count per event |
| `compute_push_data_limit(items_count, event_name, *, is_default_dataset=False)` | `int` | Items that fit in the budget |
| `is_event_charge_limit_reached(event_name)` | `bool` | Whether this event type is exhausted |

```python
manager = Actor.get_charging_manager()
room = manager.calculate_max_event_charge_count_within_limit("page-processed")
batch = pending if room is None else pending[:room]
```

## API clients

The SDK gives you two clients. Both use the run's token unless you pass another one.

`Actor.apify_client` returns a cached `ApifyClientAsync` (also exported as `ScrapelyClientAsync`) from `scrapely-client-python`, whose import name is `scrapely_client`. It's the full Scrape.ly API client, configured with the run's API URL and token. Pushes to the run's default dataset through this client respect the pay-per-event budget. Use it for anything the `Actor` methods don't cover:

```python
client = Actor.apify_client
items = (await client.dataset(run.default_dataset_id).list_items()).items
```

For the client's full surface, and for using it outside an Actor, see the [Python API client](https://scrape.ly/docs/client-python.md).

`Actor.new_client(token=None, base_url=None)` returns the SDK's small internal HTTP client (`scrapely.storage.ApiClient`). It's synchronous to create, and its methods are async: `get()`, `post()`, `put()`, `delete()`, `head()`, `get_json()`, `post_json()` and `put_json()`. The `*_json` helpers unwrap the API's `data` envelope. It retries HTTP 429 up to 3 times and raises `ApiClientError` with `status_code` and `body` on any other error status.

```python
client = Actor.new_client()
run_info = await client.get_json(f"/v2/actor-runs/{run.id}")
```

## What's next

- [Errors & deployment](https://scrape.ly/docs/sdk-python-deploy.md) covers how failures surface.
- The [API reference](https://scrape.ly/docs/sdk-python-reference.md) lists every exported symbol.
- [Run Actors](https://scrape.ly/docs/run-actors.md) explains runs from the caller's side.

---

- Previous: [Events & state](https://scrape.ly/docs/sdk-python-events.md)
- Next: [Environment & proxy](https://scrape.ly/docs/sdk-python-environment.md)
- All docs: [Docs index](https://scrape.ly/docs.md), [llms.txt](https://scrape.ly/llms.txt)
