| id | upgrading-to-v3 |
|---|---|
| title | Upgrading to v3 |
| sidebar_label | Upgrading to v3 |
| description | Breaking changes to be aware of when upgrading to version 3 of the Apify API client for JavaScript. |
import ApiLink from '@theme/ApiLink';
This page summarizes the breaking changes when upgrading from v2 to v3 of apify-client.
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'; // v3A 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.
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.
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 string at `limit`, got `ten`
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.
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.
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 `timeout`, got `Infinity`
await client.actor('my-actor').call(undefined, { timeout: 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, timeout 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.
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.
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 Telling API errors apart.
Two things change as a result:
error.name, and with it the first line of the printed stack, now carries the subclass name, such asNotFoundError: Actor task was not foundinstead ofApifyApiError: Actor task was not found. Log tooling that matches on theApifyApiErrorname has to match the subclass names as well.- Methods that swallow a 404 response, such as
get()returningundefinedordelete()succeeding silently, now swallow every 404, whatever itstype. In v2 they swallowed only therecord-not-foundandrecord-or-token-not-foundtypes and threw for any other 404. The same helper backswaitForFinish()andcall(), which read a swallowed 404 as "the run is not visible yet", so a 404 that used to throw now keeps them polling untilwaitSecsruns out.
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:
- Clients chained off a run or build without an ID:
run.dataset(),run.keyValueStore(),run.requestQueue(),run.log()andbuild.log(). Theirget()anddelete()throw on a 404, and so doeslog().get().client.log(id).get()keeps resolving toundefined. - Singleton endpoints at a fixed path under a resource:
DatasetClient.getStatistics(),UserClient.monthlyUsage(),UserClient.limits(),ScheduleClient.getLog(),TaskClient.getInput()andWebhookClient.test(). A 404 there means the parent resource is gone, so these throw as well, and their return types drop| undefined.
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() 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);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.
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.lastDispatchwas typed as astring, even though the API returns an object. It's now optional and nullable, typed asWebhookLastDispatch.Schedule.nextRunAt,Schedule.lastRunAt,RequestQueue.expireAtandRequestQueueClientRequestSchema.handledAtwere typed asstring, even thoughparseDateFields()has always converted them toDate. They're now typed as such.handledAtalso carries into whatupdateRequest()takes, so a call that marked a request handled with an ISO string has to pass aDateinstead.Build.statuswas typed as the four terminal statuses, even thoughwaitForFinish()documentsREADYandRUNNING. It's now all eight Actor job statuses, so an exhaustiveswitchover it no longer compiles.WebhookDispatch.webhookwasPick<Webhook, 'requestUrl' | 'isAdHoc'>. It's now an optional, nullableWebhookDispatchWebhookSummary, which also carriesactionTypeand aconditiontyped as the sameWebhookConditionunionWebhook.conditioncarries.UserPlan.enabledPlatformFeatureswas aPlatformFeature[], even though the platform has features that enum never gained, such asPROXY_RESIDENTIAL. It's now astring[].PlatformFeaturestays published, so an existing comparison against one of its members still works.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 carryrequestId,wasAlreadyPresentandwasAlreadyHandled. The delete endpoint answers with none of those, so the return type is nowRequestQueueClientBatchDeleteRequestsResult, whose processed entries carryidanduniqueKey. Code that read any of the three old fields was readingundefined.
A few changes need more than a null check.
parseDateFields()'s recursion depth increased from 3 to 4, so a list response, such as from webhook.dispatches().list(), gets the same Date conversion as the single resource it wraps.
The extra level applies to every response, so the conversion also reaches one step further into the caller-owned blobs the API stores verbatim. A listed request's userData.foo.somethingAt comes back as a Date instead of the string it was written as, and so does a somethingAt three levels inside a task's input.
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 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.
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.
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.
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.
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 astring, even though the endpoint returns the log as a list of entries. It's now typed asScheduleInvoked[], each entry carryingmessage,levelandcreatedAt.TaskPublicConfignow follows the specification:publishedAtis optional and read-only, andcategorization, which the specification doesn't describe, is gone from the type.
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; // trueThe 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.
ActorVersionCollectionClient.list() and ActorEnvVarCollectionClient.list() now take no arguments. 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 an options object no longer compiles. Drop the argument and the call returns the same items as before.
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.
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 }); // v3A 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' }); // v3Dropping 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.