Skip to main content
Version: Next

Upgrading to v3

This page summarizes the breaking changes when upgrading from v2 to v3 of apify-client.

The package is now pure ESM

apify-client ships as an ES module. The CommonJS build is gone, along with the dist/index.mjs wrapper and the require condition in exports, so import is the supported way to load the client.

- const { ApifyClient } = require('apify-client'); // v2
+ import { ApifyClient } from 'apify-client'; // v3

A CommonJS project can keep calling require('apify-client'): the client has no top-level await, and Node.js 22.12 and newer load an ES module through require() directly. On Node.js 22.0 to 22.11, require() of an ES module is still behind the --experimental-require-module flag, so use import there.

The browser bundle at dist/bundle.js is now an ES module instead of UMD, so it no longer defines an Apify global. Importing it, whether through a bundler or the apify-client/browser subpath, is unchanged. A classic <script> tag that read Apify.ApifyClient off the global has to become a <script type="module"> that imports it instead. For details, see Bundled environments.

TypeScript 5.9 or newer is required

The published types build on type-fest 5, which needs TypeScript 5.9 or newer. v2 used type-fest 4, which worked with TypeScript 5.1. Plain JavaScript projects are unaffected.

Argument validation switched from ow to zod

The client now validates the arguments you pass with zod instead of ow. This changes what gets thrown for invalid arguments, and tightens a few gaps ow used to let through silently.

A new error type

Invalid arguments now throw an ArgumentValidationError (exported from apify-client), not ow's ArgumentError. Its message is a plain, human-readable sentence naming the offending field and the value it received, rather than ow's JSON dump:

- Expected property string `countryCode` to match `/^[A-Z]{2}$/`, got `CZE` in object // v2 (ow)
+ Invalid string: must match pattern /^[A-Z]{2}$/ at `countryCode`, got `CZE` // v3 (zod)

The structured zod issues are available on issues, and the original ZodError on cause:

import { ApifyClient, ArgumentValidationError } from 'apify-client';

const client = new ApifyClient({ token: 'MY-APIFY-TOKEN' });

try {
await client.dataset('my-dataset').listItems({ limit: 'ten' });
} catch (error) {
if (error instanceof ArgumentValidationError) {
console.log(error.message); // Invalid input: expected number, received the string `ten` at `limit` in `DatasetClientListItemOptions`
console.log(error.issues); // [{ code: 'invalid_type', expected: 'number', path: ['limit'], ... }]
}
}

If you were matching on ow's ArgumentError, switch to ArgumentValidationError. If you were parsing the old message text, use issues instead.

Arrays and functions no longer pass where a plain object is expected

ow's object check let arrays and functions through wherever a plain object was expected. Zod's does not, so passing one now throws instead of reaching the API with a nonsensical body. This affects update() / create() fields, TaskClient.start() / call() input, the storage schema option, DatasetClient.pushItems() items, and RequestQueueClient.addRequest() / batchAddRequests() requests.

// Now throws: Invalid input: expected object, received array
await client.actor('my-actor').update([{ name: 'my-actor' }]);

Date, Map, Set and other class instances still pass as objects, same as under ow.

Infinity and invalid Dates are now rejected

ow only checked the type, so Infinity passed as a number and an invalid Date passed as a date. Zod additionally requires a finite number and a valid date, so both now throw:

// Now throws: Invalid input: expected a finite number at `runTimeoutSecs`, got `Infinity`
await client.actor('my-actor').call(undefined, { runTimeoutSecs: Infinity });

// Now throws: Invalid input: expected a valid date at `startedBefore`
// Invalid input: expected string, received Date at `startedBefore`
await client.actor('my-actor').runs().list({ startedBefore: new Date('nonsense') });

The second example reports a line per arm, because startedBefore accepts either a Date or a string.

This affects numeric options such as waitSecs, runTimeoutSecs and memory, and date options such as startedBefore / startedAfter. KeyValueStoreClient.setRecord() rejects NaN and Infinity as a record value too, since JSON.stringify() turns both into null.

A few always-rejected options are gone from the types

Some options were declared in the TypeScript types but always rejected by the client's own validation before a request was ever sent: chunkSize on DatasetClient.downloadItems() and createItemsPublicUrl(), and signature on createItemsPublicUrl() and createKeysPublicUrl(). These are no longer part of the option types, so passing them is now a compile-time error instead of a runtime throw.

The reverse also happened: chunkSize now works on every list() method that takes pagination options. In v2 only DatasetClient.listItems() accepted it - everywhere else it type-checked and then threw.

API errors are thrown as subclasses of ApifyApiError

An error response from the API now throws the ApifyApiError subclass matching its HTTP status code: InvalidRequestError (400), UnauthorizedError (401), ForbiddenError (403), NotFoundError (404), ConflictError (409), RateLimitError (429) or ServerError (5xx). Any other status code still throws a plain ApifyApiError. Every subclass extends ApifyApiError, so existing instanceof ApifyApiError checks keep working. For details, see Error subclasses.

Two things change as a result:

  • error.name, and with it the first line of the printed stack, now carries the subclass name, such as NotFoundError: Actor task was not found instead of ApifyApiError: Actor task was not found. Log tooling that matches on the ApifyApiError name has to match the subclass names as well.
  • Methods that swallow a 404 response, such as get() returning undefined or delete() succeeding silently, now swallow every 404, whatever its type. In v2 they swallowed only the record-not-found and record-or-token-not-found types and threw for any other 404. The same helper backs waitForFinish() and call(), which read a swallowed 404 as "the run is not visible yet", so a 404 that used to throw now keeps them polling until waitSecs runs out.

A 404 throws where it used to resolve to undefined

Fetching a resource by ID still resolves to undefined when the API answers 404, and delete() on such a client still resolves without error. The change affects endpoints where a 404 can't be pinned to one resource: the missing thing may be the parent or the sub-resource, and the response doesn't say which. Those now throw an ApifyApiError with statusCode 404 instead of hiding the cause behind undefined.

import { ApifyApiError, ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: 'MY-APIFY-TOKEN' });

// v2: resolved to undefined on 404. v3: throws.
let dataset;
try {
dataset = await client.run('run-id').dataset().get();
} catch (error) {
if (!(error instanceof ApifyApiError) || error.statusCode !== 404) throw error;
}

Affected calls:

Lookups by key keep the old behavior, because there the 404 is about the record itself: KeyValueStoreClient.getRecord() and RequestQueueClient.getRequest() still resolve to undefined, and KeyValueStoreClient.recordExists() still answers false. ActorClient.lastRun() and TaskClient.lastRun() also keep resolving to undefined, where having no run yet is an ordinary outcome.

LogClient.stream() follows the same rule as get(): client.log(id).stream() resolves to undefined on a 404, run.log().stream() throws.

A StreamedLog whose run no longer exists logs a warning and stops, the same way it handles any other error while streaming.

UserClient.get() declares the undefined it could always return

UserClient.get() is typed Promise<User | undefined>. It addresses a user by ID, so it belongs with the calls that read a 404 as a missing resource, and it already resolved to undefined for one. Only its signature said otherwise, which left the undefined to surface as a runtime error somewhere further along. Every other get() on the client is typed this way, as is get() on the Python client.

- const user = await client.user('some-id').get();
- console.log(user.username);
+ const user = await client.user('some-id').get();
+ console.log(user?.username);

An empty string is no longer accepted as a version number or environment variable name

ActorClient.version(), ActorClient.build() and ActorVersionClient.envVar() now throw an ArgumentValidationError for an empty string, which is what every other resource identifier has always done.

An empty identifier used to build the URL of the whole collection instead of one member, so actor.version('') read every version of the Actor and a 404 no longer meant a missing version. Rejecting it up front keeps the 404 rules above unambiguous.

Published types now follow the OpenAPI specification

Every output type the client publishes, such as Dataset, KeyValueStore, Build, ActorRun, Webhook, Schedule, Task, RequestQueue, and User, is now declared on top of a type generated from the published OpenAPI specification instead of being hand-written. Several of the previous hand-written types were wrong, and some even contradicted the client's own runtime behavior. For example, nextExclusiveStartKey was typed as a required string, but listKeys() has always compared it to null.

For most consumers, the change only surfaces as new compiler errors. Many fields that were typed as required are now optional (field?: T) or nullable (field: T | null) to match what the API can actually return. Recompile your project and add the null and undefined checks the compiler points out. These type corrections don't change what the client returns at runtime, only what TypeScript claimed about it before.

A few fields went the other way and became required. ActorVersion.versionNumber is one, and ActorVersion is what create() takes, so a call that omitted the version number no longer compiles.

A handful of fields and return types also change entirely to match the client's actual behavior:

  • Webhook.lastDispatch was typed as a string, even though the API returns an object. It's now optional and nullable, typed as WebhookLastDispatch.
  • Schedule.nextRunAt, Schedule.lastRunAt and RequestQueueClientRequestSchema.handledAt were typed as string, even though the client has always converted them to Date. They're now typed as such. handledAt also carries into what updateRequest() takes, so a call that marked a request handled with an ISO string has to pass a Date instead.
  • Build.status was typed as the four terminal statuses, even though waitForFinish() documents READY and RUNNING. It's now all eight Actor job statuses, so an exhaustive switch over it no longer compiles.
  • WebhookDispatch.webhook was Pick<Webhook, 'requestUrl' | 'isAdHoc'>. It's now an optional, nullable WebhookDispatchWebhookSummary, which also carries actionType and a condition typed as the same WebhookCondition union Webhook.condition carries.
  • UserPlan.enabledPlatformFeatures was a PlatformFeature[], even though the platform has features that enum never gained, such as PROXY_RESIDENTIAL. It's now a string[]. PlatformFeature stays published, so an existing comparison against one of its members still works.
  • RequestQueue.expireAt is gone. The API does not return it on a request queue, so reading it gave undefined on every queue you ever fetched.
  • getRequest() was typed as a queue-head projection, even though the endpoint returns the whole request. It's now the full request schema.
  • batchDeleteRequests() was typed with the batch add result, whose processed entries carry requestId, wasAlreadyPresent and wasAlreadyHandled. The delete endpoint answers with none of those, so the return type is now RequestQueueClientBatchDeleteRequestsResult, whose processed entries carry id and uniqueKey. Code that read any of the three old fields was reading undefined.

A few changes need more than a null check.

A resource and its list item are no longer interchangeable

The specification describes a full resource and its list item as two different shapes, so ActorRun no longer extends ActorRunListItem, and a Build still isn't assignable to BuildCollectionClientListItem, which requires the usageTotalUsd that only the list endpoint always returns. Code that passes a full resource where a list item is expected needs to change.

An Actor version's source files can be folders

An Actor version's sourceFiles is a flat list that mixes files and folders, so its element type is now ActorVersionSourceFile or the new ActorVersionSourceFolder. Code that reads content or format off an element has to tell the two apart first, by the folder flag only a folder carries. The ActorVersion union also gains a fifth variant for SOURCE_CODE, ActorVersionSourceCode, so an exhaustive switch over sourceType no longer compiles.

Source types and scheduled-action types are plain strings

ActorVersion.sourceType is typed as 'SOURCE_FILES' | 'GIT_REPO' | 'TARBALL' | 'GITHUB_GIST' | 'SOURCE_CODE' instead of the ActorSourceType enum, and a scheduled action's type as 'RUN_ACTOR' | 'RUN_ACTOR_TASK' instead of ScheduleActions. Both enums stay published and their members stay assignable, so code that writes sourceType: ActorSourceType.GitRepo, or switches over the enum's members, still compiles.

What breaks is reading the value back into a variable or parameter annotated with the enum. const type: ActorSourceType = version.sourceType no longer compiles. Annotate it as ActorVersion['sourceType'] instead, or leave it to inference.

The request-queue head splits into two item types

RequestQueueClientRequestSchema is now derived from the specification's stored-request schema. Its id, url and uniqueKey stay required, as the specification states them, and the rest of the fields follow the specification's optionality.

The queue head splits into two item types. listHead() still yields RequestQueueClientListItem, which drops lockExpiresAt, while listAndLockHead() now yields the new RequestQueueClientLockedListItem, where the field is required.

Submitting a request is unchanged: addRequest() and batchAddRequests() take RequestQueueClientRequestToAdd, which is the stored request without the id the API assigns.

A scheduled Actor-task action takes its input as an object

ScheduleActionRunActorTask.input was typed as a string, and is now the object the specification describes. The same type backs update(), so an action that passed its input as a JSON string has to pass the parsed object instead.

For the full per-resource breakdown of what became optional, nullable, newly exposed, or dropped, see the BREAKING CHANGE commit footer of #985.

Responses are validated against the OpenAPI specification

Every response the client turns into a typed value is now checked against a zod schema generated from the same specification the types come from, the way the Python client validates its responses with pydantic. A response that doesn't match, whether a missing required field, a different type, or a value outside the documented range, throws a new ResponseValidationError (exported from apify-client) instead of being handed on as if it were what the type claims.

The check is deliberately lenient about growth: fields the specification doesn't describe pass through untouched, and an enum value it doesn't list is accepted too, so a new field or status on the API side isn't an error. What it catches is the API and its specification disagreeing, which previously surfaced as an undefined somewhere down the line. If you hit one, the specification is wrong or the API changed, so please report it.

import { ApifyClient, ResponseValidationError } from 'apify-client';

const client = new ApifyClient({ token: 'MY-APIFY-TOKEN' });

try {
await client.actor('my-actor').get();
} catch (error) {
if (error instanceof ResponseValidationError) {
console.log(error.message);
// Response from GET https://api.apify.com/v2/acts/my-actor does not match the API schema:
// Invalid input: expected string, received null at `name`
// The API returned something its OpenAPI specification does not describe. Please report this at https://github.com/apify/apify-client-js/issues.
console.log(error.issues); // [{ code: 'invalid_type', expected: 'string', path: ['name'], ... }]
}
}

Bodies the specification leaves to you aren't validated: dataset items, key-value store records and logs are returned as before.

Two return types change as a result of describing what the endpoints really return:

  • ScheduleClient.getLog() was typed as a string, even though the endpoint returns the log as a list of entries. It's now typed as ScheduleInvoked[], each entry carrying message, level and createdAt.
  • TaskPublicConfig now follows the specification: publishedAt is optional and read-only, and categorization, which the specification doesn't describe, is gone from the type.

Date fields are converted by the schemas

The Date conversion moved into the schemas. A field the specification declares as a date-time comes back as a Date, wherever it sits in the response, and nothing else is touched. v2 walked every response and converted any field whose name ends in At, three levels deep, and passed the field on as a string when it didn't parse as a date.

  • Date strings inside the bodies the API stores for you stay strings. A somethingAt in a request's userData or in a task's input is returned as written, so parse it yourself where you need a Date.
  • A date-time field that carries anything other than an ISO 8601 date-time with a Z or a time-zone offset throws a ResponseValidationError, where v2 handed the string on.
  • The field name no longer matters. dailyServiceUsages[].date on UserClient.monthlyUsage() is converted because the specification says so.
  • Nothing is skipped for depth. webhook.dispatches().list() returns calls[].startedAt as a Date on every listed dispatch, where v2 left it a string for sitting one level too deep.

URL fields are normalized

Fields the specification marks as a URL, such as ActorRun.containerUrl or Dataset.consoleUrl, are parsed with the WHATWG URL parser as part of response validation, and the client hands back the parsed URL's serialization. In v2 you got the raw string from the API. In v3 the string can differ, most visibly by an added trailing slash. Normalization also lowercases the host, drops a default port, punycodes an internationalized host, and percent-encodes unsafe characters. Both forms denote the same URL under RFC 3986. They're just different strings.

// An empty path becomes '/'.
new URL('https://abc123.runs.apify.net').href; // 'https://abc123.runs.apify.net/'

// The host is lowercased.
new URL('https://EXAMPLE.com/Path').href; // 'https://example.com/Path'

// A default port is dropped.
new URL('https://example.com:443/path').href; // 'https://example.com/path'

// An internationalized host is punycoded.
new URL('https://www.žluty.cz').href; // 'https://www.xn--luty-kbb.cz/'

// Unsafe characters are percent-encoded.
new URL('https://example.com/a b').href; // 'https://example.com/a%20b'

Code that compares a stored URL with a URL field has to compare normalized values:

const run = await client.run('my-run-id').get();
const storedUrl = 'https://abc123.runs.apify.net';

// The raw string no longer matches.
storedUrl === run.containerUrl; // false

// Normalize the stored side too.
new URL(storedUrl).href === run.containerUrl; // true

The affected fields are ActorRun.containerUrl, Task.standbyUrl, Webhook.requestUrl (on the full webhook, on a list item, and on the webhook summary a dispatch carries), Dataset.consoleUrl, Dataset.itemsPublicUrl, KeyValueStore.consoleUrl, KeyValueStore.keysPublicUrl, KeyValueStore.recordsPublicUrl, KeyValueListItem.recordPublicUrl, RequestQueue.consoleUrl, ActorStoreList.url, ActorStoreList.userPictureUrl, UserProfile.pictureUrl, and UserProfile.websiteUrl. Whether a field is normalized depends on its model. The specification doesn't mark Actor.standbyUrl, Actor.pictureUrl, ActorStoreList.pictureUrl, or the url of a request queue request as URLs, so those come back exactly as the API sent them.

A trailing slash appears only on a field the API returns without a path, so on containerUrl, standbyUrl, websiteUrl, and a Webhook.requestUrl you registered without one. The rest already carry a path, and normalization leaves it alone. To append to a URL field, use new URL('status', run.containerUrl) only when the field ends with a slash: a relative reference replaces the base's last path segment, so on consoleUrl it would drop the resource ID.

A URL field whose value isn't a valid absolute URL now fails response validation and throws ResponseValidationError, the same as any other field that doesn't match the specification.

batchAddRequests() rejects an oversized request before sending anything

batchAddRequests() now measures the whole input before it sends the first batch. A request too large for the payload limit still throws the same error, and the message still names the request's index. What changed is that nothing has been sent by the time it throws. In v2 each batch was measured as it came up, so every batch before the oversized request had already gone out.

// In v2 the four batches before the oversized request had already been sent.
// In v3 nothing is sent.
await client.requestQueue('my-queue').batchAddRequests([...hundredRequests, oversizedRequest]);

Code that treated the throw as "some of these landed" and reconciled the queue afterwards can drop the reconciliation.

A string body with a JSON content type is sent as it is

A string body sent with an explicit application/json content type now goes out verbatim. Axios used to parse it to check that it was valid JSON, and wrapped it in a JSON string literal when it wasn't. The client skips that step so that batchAddRequests() can send a body it has already serialized.

setRecord() is where you'd notice. Storing a non-JSON string under contentType: 'application/json' used to save "my value", quotes included, and now saves my value, which getRecord() can't parse back:

// v2 stored `"my value"`, v3 stores `my value`.
await client.keyValueStore('my-store').setRecord({
key: 'my-key',
value: 'my value',
contentType: 'application/json',
});

Give a record a content type matching what it holds, such as text/plain, or pass the value as an object and let the client serialize it.

The same applies to a string input passed together with a contentType to start(), call(), or metamorph(). v2 handed the API the string wrapped in quotes, v3 hands it over as it is.

Timeouts are configured per tier

The timeoutSecs option of the ApifyClient constructor is gone. Every method takes its timeout from one of three tiers, and the constructor sets the duration of each tier, along with a cap on any single request attempt:

- const client = new ApifyClient({ token: 'MY-APIFY-TOKEN', timeoutSecs: 360 }); // v2
+ const client = new ApifyClient({ // v3
+ token: 'MY-APIFY-TOKEN',
+ timeoutShortSecs: 5,
+ timeoutMediumSecs: 30,
+ timeoutLongSecs: 360,
+ timeoutMaxSecs: 360,
+ });

For what each tier covers and which one a method is assigned, see Timeouts.

In v2 only the dataset, key-value store and request queue clients picked a timeout per method, and everything else ran with the global 360 seconds. A metadata call such as actor.get() now gets the 5 seconds of the short tier and a list() call the 30 of medium, so a call that used to wait out a slow API can fail sooner. RequestQueueClient.unlockRequests() moves the other way, from 30 seconds to 360. Where you need a different duration, every method that sends a request takes a timeoutSecs option, which replaces the tier of the method for that call:

await client.actor('my-actor').get({ timeoutSecs: 'long' });

timeoutSecs means the request timeout on every method, so the run timeout of ActorClient.start() and call(), TaskClient.start() and call(), and RunClient.resurrect() is renamed:

- await client.actor('my-actor').call(input, { timeout: 300 }); // v2
+ await client.actor('my-actor').call(input, { runTimeoutSecs: 300 }); // v3

TypeScript reports a leftover timeout at compile time, and in JavaScript the option schemas reject it as an unrecognized key, so the call throws an ArgumentValidationError instead of silently dropping the run limit. { timeout: 0 }, which asked for an unlimited run in v2, becomes { runTimeoutSecs: 0 }.

The timeoutSecs option of KeyValueStoreClient.setRecord() keeps its name and its meaning for a number of seconds, and now takes a tier name or 'noTimeout' as well. The same goes for client.requestQueue(id, { timeoutSecs }), which caps the tier of every request that queue client sends rather than timing each one at the given value.

client.httpClient.call() takes timeoutSecs where it read the axios timeout in milliseconds. A direct call that passed timeout: 30000 has to become timeoutSecs: 30.

Request compression is configurable

The ApifyClient constructor takes a compression option: 'brotli', 'gzip', or an HttpCompressor for a custom quality or algorithm. The default stays brotli at quality 6, so a client constructed without the option sends the same requests as in v2. For the rules that decide which bodies get compressed, see HTTP compression.

import { ApifyClient, BrotliHttpCompressor } from 'apify-client';

const client = new ApifyClient({ token: 'MY-APIFY-TOKEN', compression: 'gzip' });
const bestClient = new ApifyClient({
token: 'MY-APIFY-TOKEN',
compression: new BrotliHttpCompressor({ quality: 11 }),
});

The silent fallback is gone. In v2, a body was sent gzipped when brotli failed, and uncompressed when gzip failed too, which could happen on a runtime whose node:zlib is a polyfill or a Node.js compatibility shim. In v3 the configured compressor is the only one the client runs, and its error fails the request. On such a runtime, pass compression: 'gzip' or a custom compressor.

versions().list() and envVars().list() lose their pagination options

ActorVersionCollectionClient.list() and ActorEnvVarCollectionClient.list() accept only the timeoutSecs option. Neither endpoint reads offset, limit or desc, and both return every item in one response, so chunkSize had nothing to size either. The ActorVersionCollectionListOptions and ActorEnvVarCollectionListOptions types that declared those four options, deprecated since v2.21.0, are gone from the package. A call that passed any of them no longer compiles, and throws an ArgumentValidationError about an unrecognized key in JavaScript. Drop them and the call returns the same items as before.

limit counts scanned rows when iterating listItems()

Iterating DatasetClient.listItems() with for await pages through the dataset by offset and limit. The API applies both to the dataset's rows before clean, skipEmpty, skipHidden and unwind reshape them, so a page can return fewer items than the rows it covered, or more. v2 advanced the offset and counted down limit by the items each page returned. A filter made the next page re-read rows the previous one had covered, unwind made it skip rows, and a page that came back empty ended the loop even when rows remained behind it.

v3 advances and stops by the number of rows the API scanned, taken from the x-apify-pagination-count header. limit therefore caps the rows scanned, the same way it does on a single awaited listItems() call and in the API itself. With a filter set, the loop can yield fewer than limit items, each of them once. In v2 the same call made up the difference with items it had already yielded, or stopped early. With unwind, the loop can yield more than limit items. To collect a fixed number of items from a filtered dataset, count them in the loop:

const items = [];
for await (const item of client.dataset('dataset-id').listItems({ clean: true, chunkSize: 100 })) {
items.push(item);
if (items.length === 100) break;
}

PaginatedList.count keeps reporting the items returned on the page. The cursor-based iterators, listKeys(), listRequests() and paginateRequests(), are unchanged.

The last deprecated options are gone

Two options that carried a @deprecated marker throughout v2 have been removed.

restartOnError is gone from ActorCollectionCreateOptions, so ActorCollectionClient.create() no longer accepts it at the top level. Pass it inside defaultRunOptions instead, as the deprecation notice advised.

exclusiveStartId is gone from listRequests() and paginateRequests(). Both paginate by cursor alone now, and passing exclusiveStartId throws an ArgumentValidationError about an unrecognized key. In v2 the two were mutually exclusive, so the error about combining them is gone as well. Responses are unaffected, since the API still echoes exclusiveStartId back in the request listing.

Actor run input is no longer unknown

ActorClient.start(), call(), validateInput() and RunClient.metamorph() took their input as unknown, so any value compiled, including ones the client cannot send.

The input is now typed ActorInput, an alias for object, so it's an object or an array that the client serializes into the request body. Any other value stops compiling, including a value typed unknown, which has to be narrowed or cast first. To run an Actor without input, omit the argument or pass undefined.

- await client.actor('my-actor').call(null, { memory: 1024 }); // v2
+ await client.actor('my-actor').call(undefined, { memory: 1024 }); // v3

A raw string body stops compiling too, with or without a contentType. Pass the input as an object and let the client serialize it:

- await client.actor('my-actor').start('some=body', { contentType: 'application/x-www-form-urlencoded' }); // v2
+ await client.actor('my-actor').start({ some: 'body' }); // v3

Dropping the contentType sends the body as JSON, so the run's INPUT record changes content type with it. To keep the form encoding, pass the object and the option together: the client form-encodes an object whenever contentType is application/x-www-form-urlencoded.

metamorph()'s input is optional now, so metamorph('target-actor') compiles where it previously needed an explicit undefined.

Nothing changes at runtime. TaskClient.start() and call() keep taking a Dictionary: a task's input overrides are merged into the input saved on the task, so they are always an object.

Proxy settings from npm config are no longer read

The client sends its requests through the proxy named in the standard environment variables: HTTP_PROXY or HTTPS_PROXY for the request's scheme, ALL_PROXY as the fallback, and NO_PROXY for hosts to reach directly. Those variables work as they did in v2.

The proxy and https-proxy settings in .npmrc no longer reach the client. When a script runs under npm run, npm exports them as npm_config_proxy and npm_config_https_proxy, and v2 read those two variables as well. proxy-from-env, the package that resolves the proxy for the client's proxy-agent, dropped the npm lookups in the major that v3 pulls in. A proxy configured only in .npmrc therefore stops applying to the client's requests, and nothing warns about it. Set the standard variables instead:

export HTTP_PROXY=http://proxy.example.com:3128
export HTTPS_PROXY=http://proxy.example.com:3128

The same proxy-agent upgrade removes the [DEP0169] DeprecationWarning about url.parse() that Node.js 24 and newer printed on the client's first request.

Non-public members no longer carry an underscore

Members declared private or protected had a leading underscore on top of the keyword in v2, and the underscore is gone in v3. Code that calls only the public methods of a client is unaffected. A class that extends one of the client classes and calls a protected helper has to switch to the new name. The base classes below are not exported, so you reach these members by extending a concrete client such as ActorClient.

Classv2v3
ApiClient_url()buildUrl()
ApiClient_publicUrl()buildPublicUrl()
ApiClient_params()buildParams()
ApiClient_subResourceOptions()subResourceOptions()
ApiClient_toSafeId()toSafeId()
ApiClient_listPaginatedFromCallback()listPaginatedFromCallback()
ResourceClient_get()getResource()
ResourceClient_update()updateResource()
ResourceClient_delete()deleteResource()
ResourceClient_waitForFinish()waitForJobFinish()
ResourceCollectionClient_list()listResources()
ResourceCollectionClient_listPaginated()listResourcesPaginated()
ResourceCollectionClient_create()createResource()
ResourceCollectionClient_getOrCreate()getOrCreateResource()
RequestQueueClient_batchAddRequests()addRequestBatch()
RequestQueueClient_batchAddRequestsWithRetries()addRequestBatchWithRetries()

A helper got a suffix wherever the bare name would collide with a public method of the same class, which is why _get() is now getResource() and not get().

LoggerActorRedirect keeps _log(), since it overrides the method of that name on the Logger base class in @apify/log.

The clientMethod field on ApifyApiError is parsed from the stack trace, and a public method that delegates to one of these helpers is reported under the helper's name. An error from client.actor(id).get() says ActorClient.getResource, where v2 said ActorClient.get. Adjust anything that matches on these values in your logs.

Private members are private at runtime

Members that v2 declared private are declared with a # in v3, so the JavaScript runtime enforces the boundary, where v2 relied on the type checker. Code that reached one of them through a cast, such as (client.httpClient as any).httpAgentsPromise, throws a TypeError in v3. They also don't appear when you spread an instance, iterate Object.keys() on it, or pass it to JSON.stringify(). The protected helpers in the table keep the protected keyword, so a subclass can call them.

Node.js-only features follow the bundler's target

Four features need Node.js APIs: log streaming with LogClient.stream() and RunClient.getStreamedLog(), the stream option of KeyValueStoreClient.getRecord(), proxy support, and request body compression.

In v2 the client detected the runtime when one of them was used, so a Node.js application bundled for a browser or a neutral target kept them all. In v3 the implementation is picked when the import is resolved, so the bundler's conditions decide. Without the node condition, getRecord({ stream: true }) throws, getStreamedLog() returns undefined, and requests go out unproxied, uncompressed, and without the client's User-Agent header.

Enable the node condition when you bundle for Node.js. esbuild sets it through platform: 'node', webpack through resolve.conditionNames, and Vite through resolve.conditions.

A test runner that resolves the browser condition decides the same way. Jest's jsdom environment resolves it, so a Node.js test suite running under it loses them unless you list the node condition:

// jest.config.js
export default {
testEnvironment: 'jsdom',
testEnvironmentOptions: { customExportConditions: ['node'] },
};

Running on Node.js without a bundler is unaffected, and the pre-built browser bundle behaves as it did in v2. For what bundling for a non-Node.js target takes, see Bundled environments.

Response bodies are decoded by TextDecoder

In Node.js, v2 decoded response bodies with Buffer and v3 decodes them with TextDecoder. The two support different charsets, so a content-type header carrying one can be handled differently:

  • iso-8859-1 and other charsets only TextDecoder knows decode to a string, where v2 handed back raw bytes.
  • hex, base64, binary, ucs2 and utf16le, which only Buffer knows, come back as raw bytes, where v2 decoded the body as if it were in that encoding.
  • ascii is read as an alias for windows-1252, so a byte above 0x7F decodes to the character that encoding gives it, where v2 masked the byte down to seven bits.

Code that depended on one of these has to convert the value itself. A body with no charset or with a UTF-8 one is unaffected, and that covers everything the Apify API sends.

Request compression covers more body types

v2 compressed a request body only when it was a string or a Buffer. A Uint8Array, another typed array, or an ArrayBuffer is compressed as well once it reaches the same 1 kB threshold, so a request carrying one gains a content-encoding header. The API accepts both encodings the client sends, br and gzip, so nothing needs to change on your side.

The HTTP client is pluggable

ApifyClient sends its requests through an HTTP client you can replace. HttpClient is now the abstract base holding the shared request pipeline, and the axios-based client the ApifyClient uses by default is AxiosHttpClient. A custom client extends HttpClient, implements sendRequest() and is plugged in with ApifyClient.withCustomHttpClient(). See HTTP clients for the contract. Decoupling the pipeline from axios changes a few details of the public surface.

requestInterceptors moved to AxiosHttpClient

Axios request interceptors are a feature of the axios transport, so the option left ApifyClientOptions. An interceptor that only adds a fixed header to every request, such as x-apify-integration-platform, can be replaced by the new headers option of ApifyClientOptions, without touching axios:

const client = new ApifyClient({
token: 'MY-APIFY-TOKEN',
- requestInterceptors: [
- (config) => {
- config.headers['x-apify-integration-platform'] = 'my-platform';
- return config;
- },
- ],
+ headers: { 'x-apify-integration-platform': 'my-platform' },
});

An interceptor that computes something per request, such as a token read at call time, goes to an AxiosHttpClient that you plug in. The retry and timeout options move along with it:

- import { ApifyClient } from 'apify-client';
+ import { ApifyClient, AxiosHttpClient } from 'apify-client';

- const client = new ApifyClient({
- token: 'MY-APIFY-TOKEN',
- maxRetries: 4,
- requestInterceptors: [addRequestId],
- });
+ const client = ApifyClient.withCustomHttpClient({
+ token: 'MY-APIFY-TOKEN',
+ httpClient: new AxiosHttpClient({ maxRetries: 4, requestInterceptors: [addRequestId] }),
+ });

An interceptor now sees the request as the shared pipeline prepared it: the headers merged, the query string already encoded into url so params is empty, and the body already serialized to a string or Buffer and compressed. Under v2 the interceptors ran before serialization and saw the original object.

ApifyRequestConfig and ApifyResponse no longer extend the axios types

The request httpClient.call() takes and the response it resolves to, which RunClient.charge() also returns, are now transport-neutral. ApifyResponse carries status, headers, data and config, without the statusText, request and axios-specific config fields. ApifyRequestConfig accepts url, method, params, headers, data, timeoutSecs, responseType, stringifyFunctions, doNotRetryTimeouts and signal, so an axios-only option such as maxRedirects is rejected by the types. The forceBuffer flag became responseType: 'buffer', and responseType takes 'parsed', 'buffer' or 'stream' instead of the axios values.

InvalidResponseBodyError.response is the transport's response

The response property carries the HttpResponse the transport returned, with the unparsed bytes in body, in place of the axios response with the failed body in data.

ApifyApiError.httpMethod is uppercase

The method is reported as the client sent it, so POST where v2 reported axios's lowercased post. ResponseValidationError takes its method from the same place, so its message is uppercase too.

A Blob, File or FormData body is no longer sent

Under v2 axios recognized these types and streamed them, taking the Content-Type and Content-Length, or the multipart boundary, from the value itself. The pipeline serializes bodies on its own now and doesn't handle them, so passing one throws a TypeError naming the type. Read a Blob or a File into a Buffer or an ArrayBuffer first. A FormData body has to be encoded to a Buffer or a string with a matching Content-Type header. A web ReadableStream is refused the same way, where a Readable and a URLSearchParams both still go through.

A form-encoded object body is encoded by the client

Pairing an object with Content-Type: application/x-www-form-urlencoded, which the contentType option of ActorClient.start(), TaskClient.start() and RunClient.metamorph() sets, no longer goes through axios. Binary values are base64-encoded wherever they sit in the object, so an ArrayBuffer or a typed-array field arrives intact instead of coerced to UTF-8, and a binary field nested under another key keeps its full path instead of losing it. Arrays nested below the top level are sent as key[] rather than key[0], which decodes to the same structure.

The error thrown after the retries are exhausted is the last one

ApifyClient reports the failure of the final attempt. In v2 async-retry reported the error whose message occurred most often across the attempts, so a call that hit five HTTP 500s and then four connection resets threw the ServerError rather than the reset. Code that branches on instanceof ApifyApiError for a call that fails in more than one way may see a different error type than it did before.

The per-attempt timeout grows on a fixed schedule

Each attempt gets the request's timeout doubled once per retry, capped at timeoutMaxSecs. In v2 the doubling compounded, because every attempt overwrote the request's timeout with the value it had just computed. A request starting at 5 seconds got 5, 10, 40 and 320 seconds; it now gets 5, 10, 20 and 40. Requests whose timeout is already timeoutMaxSecs are unaffected.

Retry delays are randomized

The wait before a retry is minDelayBetweenRetriesMillis doubled once per attempt and then spread over a random factor between one and two, so clients that failed together don't come back at the API in lockstep. The option is a lower bound now rather than the delay itself. At the defaults, a call that burns through all eight retries sleeps 128 to 255 seconds in total, where v2 always slept 128.

HttpClient no longer exposes the axios instance

The axios, httpAgent and httpsAgent properties, the userProvidedRequestInterceptors array and the workflowKey moved to AxiosHttpClient or became internal. Reach them through client.httpClient after narrowing it with instanceof AxiosHttpClient.

The instance itself is bare: the pipeline merges the default headers and serializes the bodies per request, so AxiosHttpClient.axios no longer carries the Authorization, X-Apify-Workflow-Key and User-Agent defaults nor the serialization interceptors. A request sent through it directly reaches the API unauthenticated. Go through httpClient.call() for an ad-hoc API request.