---
title: "Proxy & events · Scrape.ly Docs"
description: "Platform proxy, Scrape.ly browser location, platform events, state, and reboots."
url: https://scrape.ly/docs/sdk-node-proxy-events
markdown: https://scrape.ly/docs/sdk-node-proxy-events.md
---

# Proxy & events

> Platform proxy, Scrape.ly browser location, platform events, state, and reboots.

This page covers how an Actor routes traffic through proxies, reacts to platform events, keeps state across restarts, and reports progress. For proxy pricing and the dashboard Proxy page, see [Proxy](https://scrape.ly/docs/proxy.md).

## Proxy configuration

`Actor.createProxyConfiguration(options?)` returns a `ProxyConfiguration` for crawlers and `fetch` clients, or `undefined` when there's no proxy to use. It works in three modes:

| Mode | When | Traffic goes through |
| --- | --- | --- |
| Platform proxy | No `proxyUrls`, `newUrlFunction`, `tieredProxyUrls`, or `tieredProxyConfig` given, and the run has platform proxy credentials | Scrape.ly's proxy, residential by default |
| Your own proxies | Any of those options is given | The URLs you supply |
| Mixed | Your URL lists contain `'auto'` (or `'http://auto'`) | Your URLs, with `'auto'` slots served by the platform proxy |

> [!NOTE]
> These modes always apply to HTTP crawlers (`CheerioCrawler`, `HttpCrawler`, `JSDOMCrawler`, `LinkeDOMCrawler`, `BasicCrawler`). Browser crawlers use them too while the Actor's **Use Scrapely browser** setting is off (the default). With it on, browsers run on the [Scrape.ly browser](https://scrape.ly/docs/browser.md) and use its exit IP: your own proxy URLs are ignored (with a one-time warning) and the location comes from [`createBrowserConfiguration()`](#remote-browser-location).

### Platform proxy

Platform runs get proxy credentials in `SCRAPELY_PROXY_HOSTNAME`, `SCRAPELY_PROXY_PORT`, `SCRAPELY_PROXY_USERNAME`, `SCRAPELY_PROXY_PASSWORD`, and `SCRAPELY_PROXY_STATUS_URL` (mirrored as `APIFY_PROXY_*`). The password is the run ID, and the proxy only accepts connections from that run's container.

```ts
import { Actor, CheerioCrawler } from 'scrapely';

await Actor.main(async () => {
    const proxyConfiguration = await Actor.createProxyConfiguration({
        groups: ['RESIDENTIAL'],
        countryCode: 'US',
    });

    const crawler = new CheerioCrawler({
        proxyConfiguration,
        async requestHandler({ request, $, proxyInfo }) {
            await Actor.pushData({ url: request.url, title: $('title').text(), country: proxyInfo?.countryCode });
        },
    });
    await crawler.run(['https://example.com']);
});
```

| Option | Meaning |
| --- | --- |
| `groups` | `['RESIDENTIAL']` (default pool) or `['DATACENTER']`. Other groups, such as `GOOGLE_SERP`, are [refused](https://scrape.ly/docs/proxy.md#unsupported-groups) |
| `countryCode` | Two-letter ISO country code, e.g. `'US'` |
| `subdivisionCode` | ISO 3166-2 subdivision (1–3 characters), requires `countryCode`. The proxy currently routes by country only |
| `password` | Overrides the injected proxy password |
| `scrapelyProxyGroups`, `scrapelyProxyCountry`, `scrapelyProxySubdivision` | The same three options under their input-field names. The Apify aliases `apifyProxyGroups`, `apifyProxyCountry` and `apifyProxySubdivision` also work; the Scrapely name wins when both are set ([Apify names](https://scrape.ly/docs/proxy.md#apify-names)) |

The SDK encodes these options in the proxy username, e.g. `groups-RESIDENTIAL,session-abc,country-US`. Crawlers pass a session ID to keep the same exit IP per session. Session IDs are at most 50 characters from `a-z`, `A-Z`, `0-9`, `.`, `_`, and `~`; anything else throws. `ProxyInfo` from the platform proxy also carries `groups`, `countryCode`, and `subdivisionCode`.

Proxy traffic is billed per GB to the run and counts toward the run's `maxTotalChargeUsd`.

### Your own proxies

```ts
const proxyConfiguration = await Actor.createProxyConfiguration({
    proxyUrls: ['http://user:pass@proxy-a.example.com:8000', 'http://user:pass@proxy-b.example.com:8000'],
});
```

You can also pass `newUrlFunction(sessionId)`, or tiers:

```ts
const proxyConfiguration = await Actor.createProxyConfiguration({
    tieredProxyUrls: [
        [null],                                    // tier 0: no proxy
        ['auto'],                                  // tier 1: platform proxy
        ['http://user:pass@premium.example.com:8000'],
    ],
});
```

`tieredProxyConfig` takes a list of option objects instead. A tier with `proxyUrls` uses them; a tier without `proxyUrls` becomes a platform proxy tier with its own `groups` and `countryCode`. Tiers with `newUrlFunction` aren't supported and throw.

### Errors and `undefined`

`createProxyConfiguration()` returns `undefined` when:

- `useScrapelyProxy: false` is set and no proxy URLs, function, or tiers are given. With `proxyUrls`, `useScrapelyProxy: false` still uses your proxies.
- No proxy URLs, function, or tiers are given and the run has no platform proxy credentials (for example, a local run). The SDK logs a warning explaining how to set `SCRAPELY_PROXY_*` locally from the credentials on the dashboard Proxy page. Using the platform proxy from outside Scrape.ly requires an active plan or a prepaid balance of at least $1.

It throws when:

- you combine `proxyUrls` or `newUrlFunction` with `groups`, `countryCode`, or `subdivisionCode` (`Cannot combine custom proxies with Scrapely Proxy!`);
- you pass both `proxyUrls` and `newUrlFunction`;
- you use `'auto'` or a URL-less tier without platform proxy credentials;
- `countryCode` isn't two letters, or `subdivisionCode` is invalid or has no `countryCode`;
- on the platform, the proxy refuses the configuration, for example a group other than `RESIDENTIAL` or `DATACENTER` (`Proxy group "GOOGLE_SERP" is not available on Scrapely. Available groups: RESIDENTIAL, DATACENTER.`).

`checkAccess` (default `true`) sends a test request through the platform proxy (two attempts) and logs the result. When the proxy refuses the configuration, `createProxyConfiguration()` throws on the platform and logs a warning in local runs. When the check gets no answer or an HTTP error, it only logs a warning. Pass `checkAccess: false` to skip it; the proxy still refuses requests for unknown groups with `407`.

## Remote browser location

When the Actor's **Use Scrapely browser** setting is on, browser crawlers connect to the [Scrape.ly browser](https://scrape.ly/docs/browser.md) over CDP. To pick its exit location and fingerprint, create a `BrowserConfiguration` and pass it next to the crawler's other options:

```ts
import { Actor, PlaywrightCrawler } from 'scrapely';

await Actor.main(async () => {
    const browserConfiguration = await Actor.createBrowserConfiguration({
        countryCode: 'US',
        state: 'California',
        os: 'windows',
    });

    const crawler = new PlaywrightCrawler({
        browserConfiguration,
        async requestHandler({ request, page }) {
            await Actor.pushData({ url: request.url, title: await page.title() });
        },
    });
    await crawler.run(['https://example.com']);
});
```

| Option | Meaning |
| --- | --- |
| `countryCode` | Exit country, two-letter ISO code |
| `state` | Region name, e.g. `'California'` (not an ISO code) |
| `city` | City name, e.g. `'San Jose'` |
| `os` | Fingerprint OS: `'windows'`, `'macos'`, or `'android'` |
| `timezone` | IANA timezone, up to 64 characters. Defaults to the exit country's main timezone |
| `language` | Comma-separated language tags without q-values, e.g. `'de-CH,de'` (up to 128 characters) |
| `adblock` | Ad and tracker blocking; off by default |
| `solveCloudflare` | Automatic Cloudflare Turnstile solving; on by default |
| `browserCountry`, `browserState`, `browserCity` | Input-schema aliases for `countryCode`, `state`, `city` |

Invalid values throw `BrowserConfiguration: "<field>" ...`. Locally, and in builds with the setting off, the option is accepted and ignored.

The browser's location comes from the first of these that is set:

1. `browserConfiguration`;
2. the country (and subdivision, as a state) of a platform `proxyConfiguration` on the same crawler;
3. the run's default country.

Proxy groups and session IDs don't apply to the Scrape.ly browser.

## Events and graceful shutdown

```ts
Actor.on('persistState', saveCheckpoint);
Actor.off('persistState', saveCheckpoint);
```

Register listeners after `Actor.init()` (inside `Actor.main()`), so they attach to the platform event manager. `off()` without a listener removes all listeners for that event.

| Event | When | Payload |
| --- | --- | --- |
| `persistState` | Every 60 seconds (`persistStateIntervalMillis`), and before a reboot, abort, or cost-limit stop | `{ isMigrating: boolean }` |
| `systemInfo` | Periodically on platform runs | `cpuCurrentUsage`, `cpuAvgUsage`, `cpuMaxUsage`, `memCurrentBytes`, `memAvgBytes`, `memMaxBytes`, `isCpuOverloaded`, `createdAt` |
| `cpuInfo` | Periodically on platform runs | `{ isCpuOverloaded: boolean }` |
| `migrating` | A reboot was requested; the container stops a few seconds later | — |
| `aborting` | The run is being aborted: a graceful abort, an exhausted balance, or the run's cost limit | — |

`systemInfo` also feeds Crawlee's autoscaling. The SDK reconnects to the events WebSocket every 5 seconds if the connection drops.

With `gracefulShutdown` on (the default), the SDK handles the last two for you: `aborting` calls `Actor.exit()` and `migrating` calls `Actor.reboot()`, after `gracefulShutdownDelayMillis`. Don't call `Actor.reboot()` from your own `migrating` listener as well. Save state in a `persistState` listener, or use `useState()`.

After `aborting` the platform stops the container within about 30 seconds, so keep cleanup short.

`Actor.events` returns the underlying `EventEmitter`. Before `init()` it's a standalone emitter; after `init()` it's the platform event manager's.

## State persistence and migration

### `Actor.useState()`

`useState()` returns an object that is saved to a key-value store on every `persistState` event:

```ts
const state = await Actor.useState('crawl-state', { processed: 0, cursor: null as string | null });
state.processed += 1;
```

Mutate the returned object in place; don't reassign it. It must be JSON-serializable. If the record already exists (for example after a reboot), you get the saved value instead of the default.

| Call | Record key |
| --- | --- |
| `Actor.useState('crawl-state', { ... })` | `crawl-state` |
| `Actor.useState({ ... })` | `STATE` |
| `Actor.useState()` | `APIFY_GLOBAL_STATE` (legacy default) |

The third argument selects another store:

```ts
const state = await Actor.useState('crawl-state', { cursor: 0 }, { keyValueStoreName: 'state-store' });
```

### `Actor.reboot()`

`reboot()` restarts the container while keeping the run, its ID, and its storages. It:

1. emits `persistState` with `isMigrating: true` and waits 1 second;
2. sends `POST /v2/actor-runs/{runId}/reboot`;
3. waits briefly, flushes storage and charges, and calls `process.exit(0)`.

Code after `await Actor.reboot()` doesn't run. The new container starts the Actor from the beginning, so restore progress from `useState()` or your own records. Only `RUNNING`, `STANDBY`, and `READY` runs can be rebooted.

## Status messages

```ts
await Actor.setStatusMessage('Downloading page 3');
await Actor.setStatusMessage('Completed', { isStatusMessageTerminal: true });
```

`setStatusMessage(message, options?)` logs `[Status message]: <message>` at `level` (`DEBUG`, `INFO`, `WARNING`, or `ERROR`; default `INFO`) and updates the run with `PUT /v2/actor-runs/{runId}` (`statusMessage`, `isStatusMessageTerminal`). The level only affects the log line. It returns the updated run, or `{}` when there are no run credentials (local runs). It throws if the API rejects the update.

`isStatusMessageTerminal: true` marks the message as final for the run. Use it for the last message before exiting.

---

- Previous: [Storages & Crawlee](https://scrape.ly/docs/sdk-node-storages.md)
- Next: [Actors & webhooks](https://scrape.ly/docs/sdk-node-actors.md)
- All docs: [Docs index](https://scrape.ly/docs.md), [llms.txt](https://scrape.ly/llms.txt)
