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

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 IDqr, the QR code stringattempt, 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 IDack, 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 sidebaricon, emoji or Lucide icon nameorder, 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 argumentsexecute, the implementation function
Tool context:
sessionId, current session IDlogger, scoped loggerabort, 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.
Related
- Plugin getting started, build your first plugin
- PluginClient reference, available client methods
- PluginInput breakdown, what your plugin receives

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