๐ŸŽ›๏ธ Handlers#

Handlers are where your bot reacts to incoming WhatsApp updates.

In pywa, every incoming webhook update is converted into a typed update object, such as Message, CallbackButton, or MessageStatus. You register callback functions for the update types you care about, and pywa calls the right function when WhatsApp sends an update.

The usual workflow is:

  1. Create a WhatsApp client.

  2. Register handlers with decorators or Handler objects.

  3. Give WhatsApp a public callback URL.

  4. Run the app with pywa dev while developing, or pywa run when deploying.

This guide starts with the day-to-day part: writing handlers.

Your First Handler#

A handler callback receives the WhatsApp client and the update object.

main.py#
 1from pywa import WhatsApp, filters, types
 2
 3wa = WhatsApp(
 4    phone_id="1234567890",
 5    token="EAA...",
 6    verify_token="my-verify-token",
 7)
 8
 9@wa.on_message(filters.text)
10def echo(client: WhatsApp, msg: types.Message):
11    msg.reply(f"You said: {msg.text}")

Then run the file with the pywa CLI:

Terminal#
pywa dev

pywa dev starts the webhook server and reloads it when your code changes.

Registering Handlers#

You can register handlers in two main ways:

  • Decorators, which are the simplest option for most projects.

  • Handler objects, which are useful when handlers are assembled dynamically or imported from many modules.

Using decorators#

Use the on_... decorators on your WhatsApp client.

main.py#
 1from pywa import WhatsApp, types
 2
 3wa = WhatsApp(...)
 4
 5@wa.on_message
 6def handle_message(client: WhatsApp, msg: types.Message):
 7    print(msg)
 8
 9@wa.on_callback_button
10def handle_callback_button(client: WhatsApp, clb: types.CallbackButton):
11    clb.react("โค๏ธ")

You can pass filters to many handlers:

main.py#
1from pywa import WhatsApp, filters, types
2
3wa = WhatsApp(...)
4
5@wa.on_message(filters.command("start"))
6def start(client: WhatsApp, msg: types.Message):
7    msg.reply("Welcome!")

See the filters guide for built-in filters and custom filters.

Loading handlers from modules#

If your handlers live in another module and do not have direct access to the client instance, register them on the WhatsApp class.

handlers.py#
1from pywa import WhatsApp, filters, types
2
3@WhatsApp.on_message(filters.text)
4def handle_text(client: WhatsApp, msg: types.Message):
5    msg.reply(msg.text)

Then load that module when creating the client:

main.py#
1from pywa import WhatsApp
2import handlers
3
4wa = WhatsApp(
5    ...,
6    handlers_modules=[handlers],
7)

You can also load modules later:

wa.load_handlers_modules(handlers)

Using Handler objects#

For larger projects, or when handlers are created dynamically, wrap callbacks in Handler objects and register them with add_handlers().

handlers.py#
1from pywa import types
2
3def handle_message(client, msg: types.Message):
4    print(msg.text)
5
6def handle_callback_button(client, clb: types.CallbackButton):
7    print(clb.data)
main.py#
1from pywa import WhatsApp, filters, handlers
2import handlers as my_handlers
3
4wa = WhatsApp(...)
5
6wa.add_handlers(
7    handlers.MessageHandler(my_handlers.handle_message, filters.text),
8    handlers.CallbackButtonHandler(my_handlers.handle_callback_button),
9)

Available Handlers#

Decorator

Handler

Update type

on_message()

MessageHandler

Message

on_callback_button()

CallbackButtonHandler

CallbackButton

on_callback_selection()

CallbackSelectionHandler

CallbackSelection

on_flow_completion()

FlowCompletionHandler

FlowCompletion

on_flow_request()

FlowRequestHandler

FlowRequest

on_message_status()

MessageStatusHandler

MessageStatus

on_template_status_update()

TemplateStatusUpdateHandler

TemplateStatusUpdate

on_template_category_update()

TemplateCategoryUpdateHandler

TemplateCategoryUpdate

on_template_quality_update()

TemplateQualityUpdateHandler

TemplateQualityUpdate

on_template_components_update()

TemplateComponentsUpdateHandler

TemplateComponentsUpdate

on_phone_number_change()

PhoneNumberChangeHandler

PhoneNumberChange

on_identity_change()

IdentityChangeHandler

IdentityChange

on_call_connect()

CallConnectHandler

CallConnect

on_call_terminate()

CallTerminateHandler

CallTerminate

on_call_status()

CallStatusHandler

CallStatus

on_call_permission_update()

CallPermissionUpdateHandler

CallPermissionUpdate

on_user_marketing_preferences()

UserMarketingPreferencesHandler

UserMarketingPreferences

on_edited_message()

EditedMessageHandler

EditedMessage

on_deleted_message()

DeletedMessageHandler

DeletedMessage

on_outgoing_message()

OutgoingMessageHandler

OutgoingMessage

on_outgoing_edited_message()

OutgoingEditedMessageHandler

OutgoingEditedMessage

on_outgoing_deleted_message()

OutgoingDeletedMessageHandler

OutgoingDeletedMessage

on_account_update()

AccountUpdateHandler

AccountUpdate

on_raw_update()

RawUpdateHandler

RawUpdate

Handlers or Listeners?#

Handlers are best for app entry points: commands, buttons, statuses, template events, and other updates that start a unit of work.

When you need to continue a conversation and wait for the userโ€™s next message, use a listener.

main.py#
1from pywa import WhatsApp, filters, types
2
3wa = WhatsApp(...)
4
5@wa.on_message(filters.command("start"))
6def start(client: WhatsApp, msg: types.Message):
7    age = msg.reply("Hello! What's your age?").wait_for_reply(filters.text).text
8    msg.reply(f"Nice, you are {age}.")

Read more in the listeners guide.

Making WhatsApp Reach Your App#

WhatsApp sends updates to a public HTTPS callback URL. During local development, that usually means opening a tunnel from the internet to your local server.

Pywa provides start_ngrok_tunnel() for this:

main.py#
 1from pywa import WhatsApp, filters, types, utils
 2
 3callback_url = utils.start_ngrok_tunnel(
 4    port=8000,
 5    auth_token="your-ngrok-auth-token",
 6    domain="subdomain.ngrok-free.app",
 7)
 8
 9wa = WhatsApp(
10    phone_id="1234567890",
11    token="EAA...",
12    app_id="1234567890",
13    app_secret="xxxx",
14    callback_url=callback_url,
15    verify_token="my-verify-token",
16)
17
18@wa.on_message(filters.text)
19def echo(client: WhatsApp, msg: types.Message):
20    msg.reply(msg.text)

Run the app:

Terminal#
pywa dev

Install the ngrok package before using the helper:

Terminal#
pip install ngrok

Tip

Use a static ngrok domain while developing. It keeps the callback URL stable across restarts. After the first successful registration, you can usually comment out callback_url to avoid registering the same webhook every time you restart the app.

You can also use any other HTTPS tunnel or deployed URL, such as:

Registering the Callback URL#

WhatsApp must know where to send webhook requests. You can register the callback URL automatically with pywa or manually in the Meta app dashboard.

Automatic registration#

Automatic registration is the easiest option for development and for most apps.

Pass callback_url and verify_token to WhatsApp. For the default app-level registration, also pass app_id and app_secret.

main.py#
 1from pywa import WhatsApp, utils
 2
 3wa = WhatsApp(
 4    phone_id="1234567890",
 5    token="EAA...",
 6    app_id="1234567890",
 7    app_secret="xxxx",
 8    callback_url=utils.start_ngrok_tunnel(domain="subdomain.ngrok-free.app"),
 9    verify_token="my-verify-token",
10)

When the server starts, pywa registers the URL and handles WhatsAppโ€™s verification challenge for you.

By default, pywa registers the URL in the app scope. You can use another scope with callback_url_scope:

main.py#
1from pywa import WhatsApp, utils
2
3wa = WhatsApp(
4    phone_id="1234567890",
5    token="EAA...",
6    callback_url="https://example.com",
7    verify_token="my-verify-token",
8    callback_url_scope=utils.CallbackURLScope.PHONE,
9)

The required IDs depend on the scope:

Scope

Registers

Required values

CallbackURLScope.APP

The app webhook subscription

app_id and app_secret

CallbackURLScope.WABA

A WABA alternate callback URL

waba_id

CallbackURLScope.PHONE

A phone-number alternate callback URL

phone_id

Manual registration#

If you prefer to register the URL yourself:

  1. Create the client with the same verify_token you will enter in Meta.

  2. Start the app so pywa can answer the verification challenge.

  3. Open App Dashboard > WhatsApp > Configuration.

  4. Enter your public callback URL and verify token.

Register Callback URL
main.py#
1from pywa import WhatsApp
2
3wa = WhatsApp(
4    token="EAA...",
5    verify_token="my-verify-token",
6)

Subscribing to Webhook Fields#

When registering manually, make sure your app is subscribed to the webhook fields you need.

Go to App Dashboard > WhatsApp > Configuration and scroll to Webhook Fields.

Subscribe to Webhook Fields

Pywa can process these fields:

  • messages - user messages, callbacks, and message statuses

  • calls - call connect, terminate, and status updates

  • message_template_status_update - template approval or rejection updates

  • message_template_quality_update - template quality score updates

  • message_template_components_update - template component updates

  • template_category_update - template category changes

  • user_preferences - user marketing preferences

If you register the callback URL automatically, pywa subscribes to the webhook fields it supports. You can customize the fields with webhook_fields.

Use on_raw_update() if you want to receive unsupported webhook events yourself.

Running pywa#

After handlers and callback settings are in place, run the server.

Using WhatsApp.run()#

For quick scripts and prototypes, you can start the built-in server directly from Python:

main.py#
1from pywa import WhatsApp
2
3wa = WhatsApp(...)
4
5# Register handlers here
6
7wa.run()

This uses the same Starlette-based webhook app, but it is blocking and does not include the development features of pywa dev. Prefer pywa dev and pywa run for normal use.

Using FastAPI or Flask#

If your project already has a FastAPI or Flask app, pass it to server. Pywa registers the webhook routes on that app, and you run the app yourself.

FastAPI:

main.py#
 1from fastapi import FastAPI
 2from pywa import WhatsApp, filters, types
 3
 4app = FastAPI()
 5
 6wa = WhatsApp(
 7    ...,
 8    server=app,
 9    webhook_endpoint="/whatsapp",
10)
11
12@wa.on_message(filters.text)
13def echo(client: WhatsApp, msg: types.Message):
14    msg.reply(msg.text)

Run FastAPI normally:

Terminal#
fastapi dev main.py

Flask works the same way:

app.py#
 1from flask import Flask
 2from pywa import WhatsApp, filters, types
 3
 4app = Flask(__name__)
 5
 6wa = WhatsApp(
 7    ...,
 8    server=app,
 9    webhook_endpoint="/whatsapp",
10)
11
12@wa.on_message(filters.text)
13def echo(client: WhatsApp, msg: types.Message):
14    msg.reply(msg.text)
15
16if __name__ == "__main__":
17    app.run(port=8000)

If you pass a custom FastAPI or Flask server, pywa does not run it for you.

Handler Order and Flow#

By default, pywa stops after the first handler that matches an update.

main.py#
 1from pywa import WhatsApp, types
 2
 3wa = WhatsApp(...)
 4
 5@wa.on_message
 6def first(client: WhatsApp, msg: types.Message):
 7    print("first")
 8    # No later message handlers run for this update.
 9
10@wa.on_message
11def second(client: WhatsApp, msg: types.Message):
12    print("second")

Handlers run in registration order unless you set priority. Higher priority runs earlier.

main.py#
 1from pywa import WhatsApp, types
 2
 3wa = WhatsApp(...)
 4
 5@wa.on_message(priority=1)
 6def first(client: WhatsApp, msg: types.Message):
 7    print("First:", msg)
 8
 9@wa.on_message(priority=2)
10def second(client: WhatsApp, msg: types.Message):
11    print("Second:", msg)

To keep checking later handlers by default, enable continue_handling:

main.py#
1wa = WhatsApp(..., continue_handling=True)

You can also control the flow from inside a handler with stop_handling() and continue_handling().

main.py#
 1from pywa import WhatsApp, filters, types
 2
 3wa = WhatsApp(...)
 4
 5@wa.on_message(filters.text)
 6def handle_message(client: WhatsApp, msg: types.Message):
 7    if msg.text == "stop":
 8        msg.stop_handling()
 9    else:
10        msg.continue_handling()

Validating Updates#

WhatsApp recommends validating incoming webhook requests with the X-Hub-Signature-256 header.

Pywa validates updates by default when app_secret is provided:

main.py#
1from pywa import WhatsApp
2
3wa = WhatsApp(
4    app_secret="xxxx",
5    validate_updates=True,
6    ...
7)

If no app_secret is provided, pywa disables validation and emits a warning. If validation is enabled and the signature is missing or invalid, pywa rejects the request before calling your handlers. You can disable validation explicitly with validate_updates=False.

Using Other Web Frameworks#

FastAPI and Flask are registered automatically. For any other web framework, create the HTTP routes yourself and call pywaโ€™s webhook helper methods.

Your framework needs two routes on the same endpoint:

  • GET for the verification challenge.

  • POST for incoming webhook updates.

Verification route:

main.py#
1challenge, status = wa.webhook_challenge_handler(
2    vt=request.GET[utils.HUB_VT],
3    ch=request.GET[utils.HUB_CH],
4)
5
6return challenge, status

Update route:

main.py#
 1body = request.body
 2
 3validation_error = wa.webhook_update_validator(
 4    update=body,
 5    hmac_header=request.headers.get(utils.HUB_SIG),
 6)
 7
 8if validation_error:
 9    return validation_error
10
11response, status = wa.webhook_update_handler(update=body)
12return response, status

With manual framework integration, you are responsible for returning the right response format for your framework and for running the server.

What pywa runs#

When you use pywa dev, pywa run, or run(), pywa creates a small Starlette app and runs it with Uvicorn.

That app registers:

  • A GET route on webhook_endpoint for WhatsAppโ€™s verification challenge.

  • A POST route on webhook_endpoint for incoming webhook updates.

For supported custom servers, pywa registers equivalent routes on the FastAPI or Flask app you pass to server. For other frameworks, use the manual helper methods above.