open-wa v5 is alpha. Use v4.76.0 for mature production systems unless you are validating v5.
The Client APIAPI ExplorerLicensing

Hooks reference

Find the plugin hook to use for lifecycle events, auth, messages, interceptors, routes, pages, tools, and cleanup.

Plugin Hook Rack Wally

Hooks reference

Hooks connect your plugin to the open-wa host. Your plugin's init() function returns the handlers and features it wants to use.

All hooks are optional. Use only the hook your task needs.

Lifecycle hooks

core.starting

Fires when the session is starting up.

'core.starting': async ({ config }) => {
  // config is the global wa.config.js contents
  logger.info('Session starting');
}

Payload: { config: unknown }, the global configuration object.

core.started

Fires after the session has fully started.

'core.started': async () => {
  logger.info('Session ready');
  // Start one-time setup here
}

core.stopping

Fires when the session is shutting down.

'core.stopping': async ({ reason }) => {
  logger.info('Session stopping', { reason });
  // Flush queues, save state
}

Payload: { reason?: string }, why the session is stopping.

client.ready

Fires when the WhatsApp client is connected and authenticated.

'client.ready': async ({ sessionId }) => {
  logger.info('WhatsApp connected', { sessionId });
}

Payload: { sessionId: string }, the current session identifier.

Auth hooks

auth.qr

Fires when a QR code is emitted.

'auth.qr': async ({ sessionId, qr, attempt }) => {
  logger.info('QR code emitted', { sessionId, attempt });
  // Send the QR to your own display channel
}

Payload:

  • sessionId, current session ID
  • qr, the QR code string
  • attempt, which QR emission this is (increments each time)

auth.authenticated

Fires after successful authentication.

'auth.authenticated': async ({ sessionId }) => {
  logger.info('Authenticated', { sessionId });
}

Payload: { sessionId: string }

Message hooks

message.received

Fires when a message is received.

function getTextMessage(message: unknown) {
  if (!message || typeof message !== 'object') return null;

  const candidate = message as { body?: unknown; from?: unknown; type?: unknown };
  if (candidate.type !== 'chat') return null;
  if (typeof candidate.body !== 'string' || typeof candidate.from !== 'string') {
    return null;
  }

  return { body: candidate.body, from: candidate.from, type: 'chat' };
}

'message.received': async ({ message }) => {
  const msg = getTextMessage(message);

  if (!msg) return;

  logger.info('Text message received', { from: msg.from });
}

Payload: { message: unknown }. Check the fields you need before using them.

To detect message type, check the type field:

  • 'chat', text message
  • 'image', image
  • 'video', video
  • 'audio', voice note or audio message
  • 'document', document/file
  • 'sticker', sticker
  • 'location', location share

To detect group messages, check message.isGroupMsg or whether the from field ends with @g.us.

message.sent

Fires when a message is sent.

'message.sent': async ({ message }) => {
  logger.info('Message sent', { message });
}

Payload: { message: unknown }

message.ack

Fires when a message acknowledgment is received.

'message.ack': async ({ messageId, ack }) => {
  logger.info('Message acknowledged', { messageId, ack });
}

Payload:

  • messageId, the message ID
  • ack, the acknowledgment state (number indicating delivery/read status)

Message interceptors

message.send.before

Called before a message is sent. Use it to modify outgoing content or stop your own send flow.

'message.send.before': async (
  input: { to: string; content: unknown },
  output: { content: unknown; metadata?: Record<string, unknown> }
) => {
  // Modify outgoing text before it is sent
  if (typeof output.content === 'string') {
    output.content = output.content.replace(/badword/g, '****');
  }
}

Input: { to: string; content: unknown }, the target and original content. Output: { content: unknown; metadata?: Record<string, unknown> }, modify content to change what gets sent, or add metadata.

To stop a send, throw an error or set output.content to a sentinel value that your plugin handles downstream.

Performance note: This interceptor runs for every message send. Keep it fast to avoid adding latency.

message.send.after

Called after a message is sent. Can add metadata.

'message.send.after': async (
  input: { messageId: string; to: string },
  output: { metadata?: Record<string, unknown> }
) => {
  output.metadata = { sentAt: Date.now() };
}

Input: { messageId: string; to: string } Output: { metadata?: Record<string, unknown> }

API routes

routes

Return a Hono sub-app that will be mounted at /plugins/<plugin-name>/.

import { Hono } from 'hono';

routes: () => {
  const app = new Hono();

  app.get('/status', (c) => {
    return c.json({ plugin: 'my-plugin', status: 'ok' });
  });

  app.post('/webhook', async (c) => {
    const body = await c.req.json();
    // handle incoming webhook
    return c.json({ ok: true });
  });

  return app;
}

Routes are automatically mounted and accessible at /plugins/<plugin-name>/.

See HTTP routes in plugins for more details.

Dashboard pages

pages

Declare dashboard pages this plugin wants rendered in the dashboard sidebar.

pages: [{
  path: '/',
  title: 'Plugin Status',
  icon: '📊',
  order: 1,
  description: 'Current plugin status and configuration',
}]

Fields:

  • path, route segment (becomes /plugins/<pluginName>/<path>)
  • title, display title in sidebar
  • icon, emoji or Lucide icon name
  • order, sort order (lower = higher in sidebar)
  • description, optional description for the default status view

See Dashboard pages for more details.

AI tools

tool

Register tools that can be called by AI agents or automation scripts. Keep tool descriptions narrow, validate arguments, and avoid exposing actions you do not want an agent to run.

init: async ({ client }) => ({
  tool: {
    sendWelcomeMessage: {
      description: 'Send a welcome message to a contact',
      args: {
        chatId: z.string().describe('The chat ID to send to'),
        name: z.string().describe('The contact name'),
      },
      execute: async (args, context) => {
        if (typeof args.chatId !== 'string' || typeof args.name !== 'string') {
          return 'chatId and name must be strings';
        }

        await client.sendText(
          args.chatId,
          `Welcome, ${args.name}!`
        );
        context.logger.info('Welcome message sent', { sessionId: context.sessionId });
        return `Welcome message sent to ${args.chatId}`;
      },
    },
  },
})

Tool definition:

  • description, what the tool does (shown to AI agents)
  • args, Zod schema for arguments
  • execute, the implementation function

Tool context:

  • sessionId, current session ID
  • logger, scoped logger
  • abort, AbortSignal for cancellation

The tool context does not include client. Tools that need WhatsApp methods should close over client from init() as shown above.

See AI tools for more details.

Cleanup

dispose

Called when the session is shutting down. Use this to clean up resources.

dispose: async () => {
  // Close database connections
  // Flush message queues
  // Wait for in-flight requests
  logger.info('Plugin disposed');
}

Catch-all

event

Receives every public event. Use this when you need to react to events that do not have a specific hook.

event: async ({ event, payload }) => {
  logger.debug('Event received', { event, payload });
}

Input: { event: string; payload: unknown }

Use specific hooks when possible. They are easier to test and avoid catch-all work on every event.

Wally the Walrus typing

Was this helpful?

Wally and his cute companion coffee mug are coding day and night to keep this up-to-date!

On this page