open-wa
DocsMigrate from v4 to v5

Migrate from v4 to v5

Upgrade an existing open-wa application, with before-and-after examples for startup, configuration, listeners, messages, groups, and Easy API.

Migrate from v4 to v5

This guide uses Bun and TypeScript to move an existing v4 application to v5. If you are starting a new application, use the project initializer instead.

QR codes are still the default way to link your WhatsApp account. Scan the terminal QR code from Linked Devices on your phone. Link-code login is an optional alternative.

The saved login changes. Current v5 saves an encrypted session file instead of keeping a full browser profile. Keep that file to reuse your login on later runs. When moving from v4, use a separate folder and scan once to create the new v5 session file.

v4 and earlier are legacy releases. open-wa runs against WhatsApp Web, which changes independently of your installed package. An older release can stop authenticating, sending messages, or receiving events even when you have changed nothing in your application. Pinning a package keeps your application on that package's code; it cannot pin WhatsApp Web or guarantee that the integration will keep working. This applies to older v5 releases too, so plan to keep open-wa updated.

The migration involves more than changing a version number. Some familiar methods remain, some have different inputs or results, and some v4 features have no supported replacement yet. Start with the changes below, then check the known gaps before moving a workflow that depends on them.

Release status: The CLI startup fix and restored client methods and listeners described here are changes for the next package release. They are not included in 5.4.0. That release can fail during startup before printing a QR code. Follow these examples with a release that includes the fixes. @latest selects the published npm release, not the code on master.

What needs to change?

This table describes an application that imports @open-wa/wa-automate and owns its WhatsApp browser session. Remote consumers also need the Easy API and SocketClient changes.

Your v4 codeWhat to do in v5
import { create } from '@open-wa/wa-automate'Keep it! create() returns a ready messaging client. You do not need to replace it with createClient().
client.onMessage(callback) and client.onAnyMessage(callback)Keep the callback pattern. Update any code that uses the registration result or removes listeners.
client.sendText(to, text)Keep the arguments. Handle a thrown error instead of checking for a false result.
client.kill()Replace it with client.stop() to close the browser.
ConfigObject or AdvancedConfigUse CreateOptions for the options passed to create(). Review the configuration table below.
ev.on(...) from a package-level importKeep it. Import the same shared helper in each file.
onAck((message) => ...)Keep the message callback, including message.id, message.ack, and the message details.
onStateChanged checks for CONNECTEDRead the core lifecycle with client.getState() and check for READY.
getGroupMembers() and getGroupMembersId()Keep both: the first returns contacts, the second returns IDs.
QR code pairingKeep it. Scan the terminal QR code from Linked Devices on your phone.
A saved v4 .data.json file or base64 session stringKeep a backup. Use a separate v5 session folder and scan a QR code to save the new login file.

1. Update the dependency

Stop the old bot and keep a backup of its configuration and session storage. Use a separate session-data directory for v5 and scan a QR code to link the account again. Running both bots against the same conversations with reply handlers enabled can produce duplicate replies.

Once the release containing these fixes is available, run this in your existing application directory with Bun installed:

bun add @open-wa/wa-automate@latest

You do not need to run the initializer over an existing application. If you already depend directly on other @open-wa packages, update those dependencies to compatible v5 releases too; do not combine a v4 remote client with a v5 server. See the package guide for what each package provides.

Keep imports on public package exports. Replace any private import such as @open-wa/wa-automate/dist/... with its public equivalent. The client reference lists the public types and methods.

2. Keep create and update shutdown

A typical v4 bot looks like this:

import { create } from '@open-wa/wa-automate';

const client = await create({ sessionId: 'support', multiDevice: true });

await client.onMessage(async (message) => {
  if (message.body === '!ping') {
    await client.sendText(message.from, 'Pong!');
  }
});

process.once('SIGINT', () => void client.kill());

In v5, the same bot can use this complete index.ts:

import { create } from '@open-wa/wa-automate';

const client = await create({
  sessionId: 'support',
  sessionDataPath: './v5-sessions',
});

client.onMessage(async (message) => {
  if (message.fromMe || message.body !== '!ping') return;

  try {
    await client.sendText(message.from, 'Pong!');
  } catch (error) {
    console.error('Reply failed. Check the chat before retrying:', error);
  }
});

let stopping = false;
const shutdown = async () => {
  if (stopping) return;
  stopping = true;
  await client.stop();
};

process.once('SIGINT', () => void shutdown());
process.once('SIGTERM', () => void shutdown());

console.log('Ready. Send !ping to this account from another chat.');

Run it with:

bun run index.ts

./v5-sessions stores the new session file, relative to where you run the application. The first run prints a QR code. On iPhone, open WhatsApp Settings → Linked Devices. On Android, open the WhatsApp menu and select Linked Devices. Tap Link a Device, then scan the terminal QR code. After the Ready message, send !ping from another chat and look for Pong!.

Keep ./v5-sessions/support.data.json after pairing. Restart the application with the same session ID and folder to restore the saved login. You do not need to scan a QR code on every restart.

create() chooses Puppeteer, provisions its matched browser if needed, handles terminal pairing, and resolves when the session is ready. You do not need an additional client.start() call. Browser provisioning does not install missing operating-system libraries. stop() closes the browser and leaves its saved login available for the next run. It does not log the account out of WhatsApp.

When createClient is useful

Use createClient() when you need to supply a driver, control startup explicitly, or load plugins through the core runtime. It returns an unstarted runtime, not the messaging Client. Register startup handlers first, wrap it in new Client({ client: runtime, transport: runtime.getTransport() }), then call await client.start().

That lower-level API is optional. Keep create() for a straightforward bot. You can subscribe to the shared ev before calling it to observe startup; see global events below.

3. Review configuration and saved sessions

Use CreateOptions with create(). The low-level CreateClientOptions type uses some different names and units.

SettingWith create()With low-level createClient()
sessionId, headless, executablePathKeep these names.Keep these names; also supply a driver.
qrTimeout, authTimeout, oorTimeoutValues are in seconds. create() defaults qrTimeout to 0, with no QR scan deadline.Use qrTimeoutMs, authTimeoutMs, oorTimeoutMs, in milliseconds.
chromiumArgsKeep this name.Use browserArgs.
linkCode, qrMax, licenseKeyKeep these names.Keep these names.
eventModeDefaults to true, forwarding supported listener events to ev.Same behavior.
multiDeviceRemove it. It no longer selects an authentication mode.No such option.
sessionDataPathDirectory or explicit .data.json file for compact session storage.Same behavior.
sessionDataAn encrypted compact session exported by current v5, as an object, JSON, or base64 JSON.Same behavior.
userDataDir, ephemeralRemove assumptions that these control saved login. The browser profile is disposable.Same behavior.
skipSessionSaveDisables the local session file and requires a durable remote store.Same behavior.
sessionDataEncryptionKey, sessionDataEncryptionKeyFileOptional custom key or mounted key file; choose one.Same behavior.
onError, pQueueDefault, messagePreprocessorDo not assume the shared schema wires their v4 behavior. Handle errors explicitly and configure listeners individually.No direct equivalent options.

For example, one minute is authTimeout: 60 with create() and authTimeoutMs: 60_000 with createClient(). Copying the number 60 into the millisecond option would allow only 60 milliseconds.

Keep the saved login for later runs

After pairing, v5 saves your login in <sessionId>.data.json, in the directory selected by sessionDataPath. The example above saves support.data.json in ./v5-sessions. The documentation calls this encrypted file a compact session.

Restart with the same session ID, storage path, and custom encryption key, if you supplied one. The saved login lets v5 reconnect without another QR scan. Without a custom key, the runtime uses its default encryption configuration.

V5 starts a fresh browser profile and restores the saved login from the file. You do not need to keep the browser profile directory. Keep the session file in private persistent storage because it contains login credentials. stop() closes the browser without unlinking the account or deleting its saved login.

An old v4 profile or token file cannot restore this v5 login. Keep the old storage as a backup and pair once in the separate v5 directory. If the new file is missing or unusable, or you unlink the account, you must pair again.

If no QR code appears

If v5 restores a saved login, no QR scan is needed. If startup reports Bootstrap failed, the application has failed before it can finish linking. QR pairing has not been removed.

An unusable session file can cause PORTABLE_SESSION_PROVISION_FAILED before the QR code appears. Use the separate v5 folder shown above to keep old files out of the new login path. Published 5.4.0 also has the startup issue described in the release notice at the start of this guide.

Pass configuration to an embedded application

The CLI reads wa.config.ts and WA_* environment variables. Embedded create() does not load general configuration from those sources; pass the values your application needs. Session encryption keys have their own secret-loading path and can also come from OPENWA_SESSION_DATA_ENCRYPTION_KEY or WA_SESSION_DATA_ENCRYPTION_KEY, or their _FILE variants.

import { create, type CreateOptions } from '@open-wa/wa-automate';

const options = {
  sessionId: 'support',
  sessionDataPath: './v5-sessions',
  licenseKey: process.env.WA_LICENSE_KEY,
} satisfies CreateOptions;

const client = await create(options);

See the session and login options and configuration guide for deployment settings. Keep secret values out of copied examples.

4. Update listeners and event handlers

onMessage and onAnyMessage still pass a message to your callback. Keep a fromMe guard in reply handlers so they cannot answer their own messages.

Registration now returns a listener handle immediately. Remove that particular subscription with handle.off(), instead of relying on the old Promise<Listener | boolean> result or removeListener(SimpleListener.Message):

const subscription = client.onMessage((message) => {
  console.log('Received message:', message.id);
});

// When this part of your application no longer needs the subscription:
subscription.off();

Adding await to registration is unnecessary. Pass queue options to the individual listener rather than expecting the v4 pQueueDefault option to configure it.

Keep the full acknowledgement message

Your v4 acknowledgement handler can keep reading the message:

const subscription = client.onAck((message) => {
  console.log(message.id, message.ack, message.body);
  if (message.sender) console.log('Sender:', message.sender.id);
});

The callback receives the available message snapshot when its acknowledgement changes. open-wa preserves the body, sender, chat, and extra WhatsApp fields that are present; it does not fetch the message later or reduce it to an ID and status. Some details can be absent while WhatsApp is loading a message. Use subscription.off() when you no longer need the handler.

If you already adapted to early v5's { messageId, ack } callback, use message.id instead of messageId. See acknowledgements for the delivery states.

Connection states describe the core lifecycle

The v5 core lifecycle uses STARTING, AUTHENTICATING, READY, DISCONNECTED, and STOPPED. Use client.getState() to read that lifecycle rather than treating a raw WhatsApp Web CONNECTED notification as proof of readiness. The runtime bridge can also forward browser state notifications. Do not carry over calls to forceRefocus() without a supported equivalent.

client.onStateChanged(() => {
  const state = client.getState();
  if (state === 'READY') console.log('WhatsApp is ready');
  if (state === 'DISCONNECTED') console.warn('WhatsApp disconnected');
});

Because create() resolves after startup, a listener added afterward observes future state changes. Use runtime.events before starting when you need the initial transition. onLogout also changed: its callback receives { reason?, timestamp }, rather than v4's optional boolean.

Keep the global ev import

ev is shared across imports in the same process. Register listeners before create() when you need startup events. You can also use it for your own application events between files.

Put the listener in events.ts:

import { ev } from '@open-wa/wa-automate';

ev.on('client.ready', (_event, sessionId) => {
  console.log(`Session ${sessionId} is ready`);
});

ev.on('onAck.**', ({ data: message, sessionId }) => {
  console.log(sessionId, message.id, message.ack);
});

ev.on('app.job.finished', ({ jobId }) => {
  console.log('Finished:', jobId);
});

Import it before startup in index.ts:

import './events';
import { create, ev } from '@open-wa/wa-automate';

const client = await create({ sessionId: 'support', sessionDataPath: './v5-sessions' });
ev.emit('app.job.finished', { jobId: 'daily-summary' });

onAck.** matches onAck.<sessionId>.<instanceId>. Supported listener aliases carry the v4 envelope { ts, sessionId, id, event, data }. Lifecycle aliases such as qr.**, linkCode.**, and sessionData.** call the listener with (data, sessionId, namespace). * matches one name segment and ** matches zero or more, including patterns such as **.support.

sessionData now contains the encrypted compact session object, not v4's obsolete token fields. QR values are pairing strings for your private UI. Do not send QR values, session data, or link codes to shared logs.

eventMode: false disables automatic SimpleListener aliases, while lifecycle and custom events remain available. Keep client.events when you want one client's native event stream. Use ev.off(name, callback) to remove one global subscription; stopping a session removes its forwarding connection without removing another module's global listeners. See session events for native event names and optional Effect-scoped subscriptions.

5. Review the message and group data you read

You do not need to rename every message field. The v5 message contract retains id, body, type, from, to, chatId, fromMe, isGroupMsg, sender, chat, timestamp, and t. The important changes are assumptions about optional fields and the object passed to each kind of listener.

What your handler needsWhat to use in v5
Text of a text messagemessage.body when message.type === 'chat'. The old convenience field text is not a guaranteed field in the v5 schema.
A media captionmessage.caption ?? ''; the caption is optional.
The conversation to reply toReuse message.chatId or the incoming message.from unchanged. Do not reconstruct it from a phone number.
The person speaking in a groupCheck message.author, message.senderId, or message.sender.id; do not confuse them with the group's chat ID.
Contact labels or display namesTreat sender.name, sender.pushname, and label arrays as optional.
Group metadatamessage.chat.groupMetadata can be absent or null; check it before reading subject or participants.
Contact details on a chatchat.contact can be null.

The message guide and Chat ID guide cover these fields. Preserve IDs with an @lid suffix when WhatsApp provides them. Message, contact, chat, and group IDs have distinct TypeScript types; use values returned by the corresponding API, and validate externally supplied values against the relevant identifier contract, rather than casting every string to any.

Keep the two group-member methods

The two methods keep their v4 meanings:

const contacts = await client.getGroupMembers(groupId);
for (const contact of contacts) {
  console.log(contact.id, contact.name ?? contact.pushname ?? 'Unnamed contact');
}

const ids = await client.getGroupMembersId(groupId);

Here groupId is a group ID returned by a group lookup, creation, or join operation. getGroupMembers() returns Contact[]; getGroupMembersId() returns ContactId[]. There is no need to map IDs through getContact() yourself. Use the ID-only method for membership checks that do not need contact details. If WhatsApp cannot resolve a member's contact, the contact lookup rejects rather than silently omitting that member. See the group reference.

If you adapted to early v5's ID-returning getGroupMembers(), change that call to getGroupMembersId() to keep receiving IDs.

The v5 embedded addParticipant, promoteParticipant, and demoteParticipant methods take one contact ID per call. Replace a v4 array argument with an explicit loop and handle each result. removeParticipant also takes one ID. The connected account still needs the relevant group permissions.

6. Replace renamed methods and handle changed results

These are embedded-client changes. The method reference separates embedded, SocketClient, and Easy API examples because their contracts can differ.

v4 usagev5 embedded equivalent or action
getSessionId()Read client.sessionId.
kill(reason)Call stop(reason); it resolves without the old boolean result.
logout(preserveSessionData)No equivalent embedded logout method is exposed. stop() closes the browser while preserving login; use WhatsApp's Linked Devices screen when you intend to unlink the session.
getConnectionState()Use synchronous getState() for the v5 lifecycle state. The legacy-named method is unsupported.
getAllMessagesInChat(chatId, includeMe, includeNotifications)Use getAllMessages(chatId, includeMe, includeNotifications).
getAllChats(true)Call getAllChats() and filter the result, for example by (chat.unreadCount ?? 0) > 0 for an unread-chat view. The old boolean argument is not accepted.
archiveChat(chatId, true) / archiveChat(chatId, false)Call archiveChat(chatId) / unarchiveChat(chatId).
getProfilePicFromServer(contactId)Use getProfilePic(contactId) on the embedded client.
sendLocation(to, '51.5', '-0.1', label)Pass numeric coordinates: sendLocation(to, 51.5, -0.1, label).
sendImage(to, localPathOrUrl, filename, caption)Supply image data as base64 or a data URL; the embedded method does not load a local path or fetch a URL for you. See media.
sendImageAsSticker(...)Review sendSticker(...), which accepts prepared sticker data. It is not the old general image-conversion helper.
Package-level decryptMedia(message)Use client.decryptMedia(message) for a data URL, or client.downloadMedia(message, path) to save the bytes. The return is not a Buffer.
sendText(...) returns false on failureCatch SendTextError. A successful call returns a validated message ID.

A send failure may have an uncertain outcome

Replace code such as if (!await client.sendText(...)) retry() with explicit error handling. A browser connection can fail after a message was submitted, so a blind retry can send it twice.

import { SendTextError } from '@open-wa/wa-automate';

// Inside your existing message handler:
try {
  const messageId = await client.sendText(message.from, 'Request received!');
  console.log('Submitted:', messageId);
} catch (error) {
  if (error instanceof SendTextError && error.outcome === 'unknown') {
    console.error('The send could not be confirmed. Check the chat before retrying.');
  } else {
    console.error('Send failed:', error);
  }
}

A message ID confirms submission, not delivery or reading. Use acknowledgements when your workflow needs those later states. The error change above applies to sendText; do not assume that every other send method has the same return contract.

Migrate Easy API and SocketClient

An embedded bot does not need to become an HTTP client to migrate. This section is for applications that already communicate with a separate open-wa process.

Start the v5 server

Start in a new folder so v5 does not read an old session file. Use an explicit session name and private bind address:

mkdir open-wa-v5
cd open-wa-v5
bunx --bun @open-wa/wa-automate@latest --session-id migration --host 127.0.0.1 --port 8080

Scan the terminal QR code from Linked Devices on your phone. Keep the new session file and use this same folder for later starts. Link-code login is optional. The release notice above also applies to this CLI example.

For an existing deployment, review its wa.config.ts, session-data path, API key, and CLI flags before switching traffic. Finish the pairing and readiness steps first. /health must report connected: true and session.ready: true before messaging; an HTTP listener alone does not mean WhatsApp is ready.

Update HTTP requests and response handling

Use the HTTP verb and route in the running server's API explorer. Do not assume every operation is a POST to a bare /<method> path. For example, sending text uses POST /api/messages/sendText with named fields, and the result is inside an envelope:

// send.ts: run with bun run send.ts after replacing the recipient.
const recipient = 'REPLACE_WITH_AN_EXISTING_CHAT_ID';
if (recipient.startsWith('REPLACE_')) throw new Error('Set recipient first');

const response = await fetch('http://127.0.0.1:8080/api/messages/sendText', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ to: recipient, content: 'Hello from v5!' }),
});
const result = await response.json();
if (!response.ok || !result.success) {
  throw new Error(`Send failed: ${JSON.stringify(result)}`);
}
console.log('Submitted:', result.data);

Use a chat whose recipient expects the message. If the server has an API key, include it in the X-API-Key request header. The sendText reference includes the remote request contract. Namespaced HTTP routes do not mean an embedded client must change client.sendText(...) to client.messages.sendText(...).

Replace the old remote transport

v5 SocketClient uses HTTP for commands and Server-Sent Events for events. It is not a Socket.IO connection. Update any reverse proxy rules or custom Socket.IO code accordingly.

bun add @open-wa/socket-client@latest
import { SocketClient } from '@open-wa/socket-client';

const client = await SocketClient.connect(
  'http://127.0.0.1:8080',
  process.env.WA_API_KEY,
);

client.socket.on('message.received', ({ message }) => {
  console.log('Received message:', message.id);
});

process.once('SIGINT', () => client.close());
process.once('SIGTERM', () => client.close());

The remote event payload wraps the message under message; the embedded client.onMessage() callback receives the message directly.

Keep server and client releases aligned. Closing a remote consumer ends its connection; it does not stop the server's WhatsApp browser. For webhook integrations, review the v5 envelope: the event-specific value is under payload, and an incoming message is under payload.message, instead of the v4 data field. A configured --webhook flag alone does not establish v5 delivery. The v5 CLI also does not load the shared plugin configuration; use the core plugin-loading path when migrating a webhook integration.

Keep the file, message, and group methods

The client binds these methods through the schema system to their existing private patch implementations. Keep the flat client names below. Their absence from the bundled wapi.js does not mean they were removed from the patches. The restored bindings are part of the next package release described above.

Methodv5 client behavior
sendFile(to, file, filename, caption)Sends through the existing private WAPI.sendFile implementation. The file schema accepts a file path, URL, data URL, base64, or bytes.
sendFileFromUrl(to, url, filename, caption, headers)Downloads the file on the host and delegates to sendFile.
editMessage(messageId, newContent)Calls the existing private editing method.
react(messageId, emoji)Calls the existing private reaction method.
getGroupInfo(groupId)Returns the patch's group title and description. Check for a returned error string before reading the result.
setGroupTitle(groupId, title)Calls the existing private group-title method.
setGroupDescription(groupId, description)Calls the existing private group-description method.
onParticipantsChanged(groupId, callback)Subscribes to participant changes for one group. Use handle.off() to remove the subscription.
onGlobalParticipantsChanged(callback)Subscribes to participant changes across the session's groups.

Participant callbacks retain the patch's who, chat, action, and by fields. The schema also supplies groupId and participantIds. The same change is available as group.participants.changed.global on client.events and through the shared ev listener aliases.

Known gaps

Stable v5 does not mean that every v4 feature has parity. These limitations describe the v5 embedded client and its shipped browser runtime. A method or event appearing in a generated schema is not proof that the running integration implements it.

Feature your application may usev5 limitation
Other group methodsgetGroupAdmins is not an embedded Client method.
Added-to-group notificationsonAddedToGroup is not an embedded convenience method. The optional client.events event group.addedToGroup supplies a groupId, not the full v4 chat object.
Message deletion notificationsonMessageDeleted throws an unsupported-listener error; there is no shipped deletion bridge.
Other v4 listenersDo not assume call, battery, poll, reaction, label, or commerce listeners work because their names remain in an event schema. They are not among the embedded client's supported listeners.
Chat settings and session diagnosticsEmbedded pinChat, unpinChat, muteChat, unmuteChat, getBlockedContacts, getCommonGroups, getFeatures, and getProcessStats are unsupported.
v4 media and interactive helpersHelpers such as sendPtt, sendAudio, sendAdvancedButtons, and sendListMessage are not drop-in embedded methods. Review the interactive API and its license requirements for a supported interactive workflow.
Config-driven pluginsNeither the ready create() wrapper nor the v5 CLI forwards shared plugins / pluginConfig into core startup. Use the core plugin options when you need to load plugins.
Old CLI integrations--webhook, --ready-webhook, --tunnel, and the legacy Chatwoot, Twilio, and BotPress flags do not restore their v4 behavior just because the CLI accepts or warns about them.

If a required workflow hits one of these gaps, identify that dependency before switching it. A return to v4 is not a compatibility guarantee: the same WhatsApp Web changes can affect that old release. Report the specific missing operation, your installed version, and whether you use the embedded client or Easy API in the issue tracker. Exclude session files, QR codes, API keys, and private message content.

Before switching your application over

Use your application's actual workflows to finish the migration:

  1. Start and restart the session. Pair v5, stop it, and restart with the same session ID, compact session file, and encryption key if configured. Confirm that WhatsApp restores the session.
  2. Receive a message and send a reply. Confirm the reply in the receiving chat. Check the identifiers and optional fields your handler depends on.
  3. Exercise the features you use. Include group operations, media, acknowledgements, remote requests, and integrations where applicable. A successful text reply does not establish those features' availability.
  4. Move traffic once the replacements work. Stop the old reply handlers, switch to the v5 deployment, and retain the old configuration and profile backup for investigation. Keep the new compact session storage separate.

Use release status and the API reference for the version you install to follow further changes.

Was this helpful?

Your answer includes the page path and docs version.

On this page