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:
from scrapely import Actor, AbortOptions, CallOptions, StartOptionsStart without waiting
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
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
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. |
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
await Actor.abort("run-id", AbortOptions(status_message="Superseded", gracefully=True))
await Actor.abort() # the current runabort(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
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.
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.
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.
Webhooks
Actor.add_webhook() creates an ad-hoc webhook that fires for the current run:
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.
Charging
Charging applies to Actors with pay-per-event pricing.
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
countat what the budget can cover. While the run is still under budget, it allows one extra event. When the cap is reached,charged_countis0. - 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=0withevent_charge_limit_reached=True. - Unconfirmed charges. The SDK re-sends a charge with the same key on
5xxanswers, 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()raisesChargeUncertainError, which hasoperation_id,event_nameandcount. 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 returnscharged_count=0. - Validation. A
countthat isn't a positive integer raisesValueError. 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
SIGTERMand 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.
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 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 |
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:
client = Actor.apify_client
items = (await client.dataset(run.default_dataset_id).list_items()).itemsFor the client's full surface, and for using it outside an Actor, see the Python API client.
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.
client = Actor.new_client()
run_info = await client.get_json(f"/v2/actor-runs/{run.id}")What's next
- Errors & deployment covers how failures surface.
- The API reference lists every exported symbol.
- Run Actors explains runs from the caller's side.