Skip to content
Python SDK

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:

FieldTypeMeaning
tokenstrUse this API token instead of the run token
buildstrBuild tag or number. Ignored on someone else's Actor, which always runs the User Build.
memory_mbytesintMemory in MB
timeoutint or "inherit"Run timeout in seconds. "inherit" uses this run's remaining time
max_total_charge_usdfloatSpending cap for a pay-per-event child
restart_on_errorboolRestart the child if it fails
force_permission_levelstrPermission level for the child run, for example "LIMITED_PERMISSIONS"
webhookslist[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.

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:

.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.

FieldTypeRequired
event_typeslist[WebhookEventType]Yes
request_urlstrYes
payload_templatestrNo
headers_templatestrNo
idempotency_keystrNo
do_not_retryboolNo
ignore_ssl_errorsboolNo

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.

.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:

FieldMeaning
charged_countEvents actually charged
event_charge_limit_reachedTrue when the run's budget can't cover another event of this type
chargeable_within_limitRemaining 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.

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.

MethodReturnsPurpose
get_pricing_info()ActorPricingInfoResultis_pay_per_event, pricing_model, per_event_prices, max_total_charge_usd
get_charged_event_count(event_name)intEvents charged so far
get_charged_events()dict[str, int]All charged counts
get_max_total_charge_usd()DecimalThe spending cap, or Decimal("Infinity")
get_total_charged_usd()DecimalTotal charged so far
calculate_total_charged_amount()DecimalSame total, recomputed
calculate_max_event_charge_count_within_limit(event_name)int or NoneEvents 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)intItems that fit in the budget
is_event_charge_limit_reached(event_name)boolWhether 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.

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