wats.sh
Guides

Migration: pywa to WATS

A migration map from Python pywa integrations to WATS, with honest live-parity status and known gaps.

active · reviewed 2026-06-10

WATS is a Bun-first TypeScript implementation: async-only APIs, camelCase public names, package-scoped primitives, strict runtime validation, MockTransport-first tests. This page maps pywa names to their WATS equivalents. For per-capability status, the capability status matrix is authoritative; the live campaign log is the evidence.

Status tags below use the honesty taxonomy: live-validated (exercised against live Meta), shape-only (implemented and tested locally), and planned (no WATS equivalent yet).

Package and construction map

pywa conceptWATS equivalentStatusNotes
pywa.WhatsApp(...)new GraphClient(...) plus new WhatsApp({ graphClient, phoneNumberId?, wabaId? }) from @wats/corelive-validatedWATS separates Graph transport from facade orchestration.
pywa_async.WhatsAppWATS Promise-returning APIslive-validatedWATS is async-only; there is no sync API.
wa.api / low-level Graph APIGraphClient.request, requestRaw, defineEndpointlive-validatedPrefer first-class endpoints before custom defineEndpoint.
phone_id-bound methodsPhoneNumberClient or WhatsApp with phoneNumberIdlive-validatedConstructor-bound ids are validated and cannot be caller-overridden.
business_account_id / WABA methodsWABAClient or WhatsApp with wabaIdlive-validatedTemplates, Flows, and read-only admin inventory hang from WABA.
pywa server config@wats/http adapter or @wats/servicelive-validatedWATS keeps the standalone service separate from SDK primitives.
pywa env/token constructor args@wats/config env-secret referencesshape-onlyWATS config stores { env: "..." }, never raw secrets.

Client construction and auth

pywa commonly puts token, phone id, WABA id, server, webhook, and app secret into one WhatsApp(...) constructor. WATS separates those responsibilities:

import { GraphClient, createFetchTransport } from "@wats/graph";
import { WhatsApp } from "@wats/core";

const graphClient = new GraphClient({
  accessToken: process.env.WATS_ACCESS_TOKEN!,
  apiVersion: "v25.0",
  transport: createFetchTransport()
});

const wa = new WhatsApp({
  graphClient,
  phoneNumberId: process.env.WATS_PHONE_NUMBER_ID,
  wabaId: process.env.WATS_WABA_ID
});

Migration notes:

  • Public WATS options are camelCase: phoneNumberId, wabaId, accessToken, apiVersion.
  • Graph wire names remain snake_case only at the HTTP boundary.
  • WATS constructors reject unsafe ids before transport.
  • WATS does not resolve env-secret refs in the SDK; config/service/CLI boundaries own env indirection.

Message sending map

pywa usageWATS usageStatusNotes
pywa send_message / send_textWATS PhoneNumberClient.sendText or WhatsApp.startChatlive-validatedSends text to arbitrary E.164-ish recipients without contact lookup.
send_image, send_video, send_audio, send_document, send_stickerPhoneNumberClient.sendImage / .sendVideo / .sendAudio / .sendDocument / .sendSticker or matching WhatsApp helperslive-validated (image by media id); others shape-onlyWATS accepts existing media ids or http(s) links for message sends.
pywa local file / bytes / file-like media sendsuploadAndSendImage / uploadAndSendVideo / uploadAndSendAudio / uploadAndSendDocument / uploadAndSendSticker for in-memory Blob / ArrayBuffer / Uint8Array; @wats/graph/node-media adds uploadAndSend*FromPath helpers for Node/Bun filesystem pathsshape-onlyRoot @wats/graph stays runtime-neutral; path helpers live behind the explicit Node/Bun-only subpath.
send_location / request_locationsendLocation / requestLocationshape-only
send_contactsendContactsshape-onlyWATS uses bounded arrays and strict contact shapes.
send_reaction / remove_reactionsendReaction / removeReactionshape-onlyReactions route through POST /{phoneNumberId}/messages.
pywa buttons / lists / CTA objectssendButtons, sendList, sendCtaUrl, sendVoiceCallshape-onlyWATS exposes separate helpers rather than pywa's overloaded send_message(buttons=...); sendVoiceCall emits Meta interactive.type = "voice_call".
pywa catalog/product sendssendCatalog, sendProduct, sendProductsshape-onlyRequires commerce/catalog assets for live validation.
mark_message_as_read / indicate_typingmarkMessageAsRead / indicateTypingshape-onlyOnly use on test conversations during live campaigns.
pywa trackerWATS bizOpaqueCallbackData-style inputs where supportedshape-onlyCheck the specific WATS helper; WATS uses camelCase. Not on every composer.

Media map

pywa usageWATS usageStatusNotes
upload_mediauploadMedialive-validatedValidates caps and body shape locally.
get_media_url / metadatadownloadMedialive-validatedReturns Meta media metadata / resolved URL shape.
download_media / get_media_bytesdownloadMediaByteslive-validatedUse maxBytes and integrity checks. Meta returns hex sha256 digests.
delete_mediadeleteMedialive-validatedDestructive; cleanup-only in live campaigns.
encrypted media decrypt helpersdecryptEncryptedMediashape-onlyLocal crypto/integrity checks.
resumable upload sessionscreateUploadSession, uploadFileToSession, getUploadSessionshape-onlyImport from @wats/graph or @wats/graph/endpoints/media; no PhoneNumberClient.uploadMedia convenience yet.

Templates map

pywa usageWATS usageStatusNotes
pywa create_template / get_templates / delete_templateWATS WABAClient.createMessageTemplate, .listMessageTemplates, .getMessageTemplate, .updateMessageTemplate, .deleteMessageTemplatelive-validated (list); mutations shape-onlyAuth OTP buttons use supportedApps → Graph supported_apps with nested package_name / signature_hash; auth template DSL fields autofillText / zeroTapTermsAccepted map to Graph autofill_text / zero_tap_terms_accepted. Template Group helpers: listTemplateGroups, createTemplateGroup, getTemplateGroupAnalytics.
pywa send_templatePhoneNumberClient.sendTemplate or WhatsApp.sendTemplate; PhoneNumberClient.sendMarketingTemplate for Meta /marketing_messageslive-validated (approved-template send); marketing send shape-onlyParameter-count validation runs locally. Marketing Messages status webhooks (marketing_lite) are modeled.
pywa template component DSLWATS component builders such as buildTemplateHeaderComponentshape-onlyCore HEADER/BODY/FOOTER/BUTTONS helpers exist; pywa's larger DSL is broader.
template status/category/quality/components handlersnormalizeWebhookEnvelope account helpers plus filtersTyped.templateshape-onlySynthetic webhook coverage.
compare_templates, unpause_template, migrate_templates, archive_templates, unarchive_templates, upsert_authentication_template, LibraryTemplate create fieldscompareTemplates, unpauseTemplate, migrateTemplates, archiveTemplates, unarchiveTemplates, upsertAuthenticationTemplate, and createMessageTemplate library-template fieldsshape-onlyArchive/unarchive use Meta's documented hsm_ids array body on api.facebook.com; pywa sends a comma string. migrateTemplates normalizes both documented failed_templates map responses and pywa-style list responses.

Flows map

pywa usageWATS usageStatusNotes
pywa create_flow / get_flows / publish_flowWATS WABAClient.createFlow, .listFlows, .getFlow, .publishFlowshape-onlyPublish/deprecate are state transitions; require explicit live opt-in.
update metadata / JSONupdateFlowMetadata, updateFlowJsonshape-onlyFlow JSON validation is bounded and descriptor-safe.
delete / deprecate / assetsdeleteFlow, deprecateFlow, getFlowAssetsshape-onlyDelete/deprecate only resources created by the test run.
Flow response buildersbuildFlowScreenResponse, buildFlowCloseResponse, buildFlowErrorResponseshape-onlyLocal data-exchange response construction only.
pywa FlowJSON dataclassestyped DSL builders (flowJson, screen, singleColumnLayout, form, every component + action builder)shape-onlycamelCase builders serialize to the hyphenated FlowJSON wire shape; output passes validateFlowJson.
encrypted Flow request decrypt/encryptdecryptRequest / encryptResponse / handleFlowRequest (RSA-OAEP + AES-GCM, IV-flip response), backed by @wats/cryptoshape-onlyLocal crypto round-trip; no hosted endpoint.
Flow media-upload decryptiondecryptFlowMedia, decryptFlowMediaFileshape-onlyPhotoPicker/DocumentPicker uploads use AES-CBC + HMAC-SHA256; WATS verifies encrypted hash, HMAC, and plaintext hash.
Flow metrics / migrationgetFlowMetrics and migrateFlowsshape-onlyCredentialed and account-dependent; Meta marks Metrics API deprecated after 2026-04-30, replacement unverified.

Calling map

pywa usageWATS usageStatusNotes
pywa initiate_call / accept_call / terminate_callWATS PhoneNumberClient.initiateCall, .acceptCall, .terminateCallshape-onlyAlso includes preAcceptCall and rejectCall.
call connect/status/terminate handlersnormalizeWebhookEnvelope calling variants plus filtersTyped.callshape-onlySynthetic payloads only. Calling webhook fields are normalized to camelCase; see Calling Reference and Webhook Normalizer.
call permission request/updategetCallPermissions typed permission/action/limit models; call_permission_reply webhook carries isPermanent/responseSource, plus fromUserId/fromParentUserIdshape-onlyWaiters still planned; pywa also exposes permission waiters.
calling settings/SIP modelsRead via getPhoneNumberSettings; writes via the calling sub-object on updatePhoneNumberSettings (status, call hours, call icons, callback permission, SIP servers)shape-onlyincludeSipCredentials may return secret-bearing SIP material as camelCase sipUserPassword; sip_user_password is never serialized into update bodies. WABA health_status.can_receive_call_sip is exposed as healthStatus.canReceiveCallSip.
live WebRTC/media orchestrationNo first-class WATS equivalent yetplannedCall buttons/deep links can initiate supported client calls, but WATS still has no WebRTC/SIP server implementation. Requires real calling-enabled phone numbers and explicit authorization. Meta modes include Graph APIs + webhooks + WebRTC, SIP + WebRTC, and SIP + SDES SRTP; SDP may negotiate OPUS or G.711 (PCMA/PCMU).

Business and admin map

pywa usageWATS usageStatusNotes
pywa get_business_accountWABAClient.getInfo / getWabaInfolive-validated
pywa get_business_phone_numbersWABAClient.listPhoneNumberslive-validatedSupports fields, limit, after, before.
pywa get_business_phone_number_settingsPhoneNumberClient.getSettingsshape-onlyincludeSipCredentials is sensitive; SIP password responses are exposed as sipUserPassword, not logged by WATS.
pywa phone local-storage settings updatePhoneNumberClient.updateSettings({ storageConfiguration }) / updatePhoneNumberSettingsshape-onlyEmits storage_configuration; WATS does not emit the removed register field data_localization_region on settings updates (it IS accepted on registerPhoneNumber).
pywa get_business_profile / get_commerce_settingsWATS PhoneNumberClient.getBusinessProfile / .getCommerceSettingslive-validated
pywa profile/commerce updatesWATS PhoneNumberClient.updateBusinessProfile / .updateCommerceSettings, also direct updateBusinessProfile / updateCommerceSettings from @wats/graph/endpoints/business-managementshape-onlyProfile updates map camelCase profilePictureHandle to Graph profile_picture_handle; commerce updates map isCartEnabled / isCatalogVisible to is_cart_enabled / is_catalog_visible.
pywa create_phone_numberWATS WABAClient.createPhoneNumber / createPhoneNumber from @wats/graphshape-onlyMirrors pywa WhatsApp.create_phone_number; body fields countryCode / phoneNumber / verifiedName map to Graph country_code / phone_number / verified_name.
pywa request_verification_codeWATS PhoneNumberClient.requestVerificationCode / requestVerificationCode from @wats/graphshape-onlySends code_method and language as URL query params (matches pywa + Meta), not a body.
pywa verify_phone_numberWATS PhoneNumberClient.verifyPhoneNumber / verifyPhoneNumber from @wats/graphshape-onlySends code as a URL query param; the code is never echoed in validation error messages.
pywa register_phone_numberWATS PhoneNumberClient.registerPhoneNumber / registerPhoneNumber from @wats/graphshape-onlyBody includes messaging_product: "whatsapp", pin, and optional data_localization_region; the pin is never echoed in validation error messages.
pywa deregister_phone_numberWATS PhoneNumberClient.deregisterPhoneNumber / deregisterPhoneNumber from @wats/graphshape-onlyNo body, no query params.
(no pywa equivalent) Meta two-step verification PINWATS PhoneNumberClient.setTwoStepVerificationPin / setTwoStepVerificationPin from @wats/graphshape-onlyPOST /{phoneNumberId} with body { two_step_verification: { pin } }; the pin is never echoed in validation error messages.
public keygetBusinessPublicKey, setBusinessPublicKeyshape-onlySetter mirrors pywa's form-encoded business_public_key; getter is tolerant until live-validated.
pywa create_qr_codeWATS PhoneNumberClient.createQrCode / createQrCode from @wats/graphshape-onlyPOST /{phoneNumberId}/message_qrdls with body { prefilled_message, generate_qr_image }; generateQrImage enum enforced (SVG/PNG), case-insensitive; prefilled message capped at 140 chars.
pywa get_qr_codesWATS PhoneNumberClient.listQrCodes / listQrCodes from @wats/graphshape-onlyGET /{phoneNumberId}/message_qrdls; optional fields + before/after cursor pagination.
pywa get_qr_codeWATS PhoneNumberClient.getQrCode / getQrCode from @wats/graphshape-onlyGET /{phoneNumberId}/message_qrdls/{code}; optional fields. Response is data-wrapped (per Meta/pywa).
pywa update_qr_codeWATS PhoneNumberClient.updateQrCode / updateQrCode from @wats/graphshape-onlyPOST /{phoneNumberId}/message_qrdls with body { code, prefilled_message } (same path as create, body carries existing code).
pywa delete_qr_codeWATS PhoneNumberClient.deleteQrCode / deleteQrCode from @wats/graphshape-onlyDELETE /{phoneNumberId}/message_qrdls/{code}; no body.
token exchangeexchangeBusinessAccessTokenshape-onlyExchanges Embedded Signup code for a business access token; client secret/code are never echoed in validation errors.
WABA callback overridessetWabaCallbackOverride, clearWabaCallbackOverride, plus existing listSubscribedApps readbackshape-onlyUses verify_token wire key; verify token is never echoed in validation errors. Phone-level callback override remains planned if needed.
remaining profile/commerce updatesNo first-class WATS helper yetplannedRequires explicit operator authorization when it lands.
QR code CRUD, block/unblock users, token exchangeBlock API helpers exist (listBlockedUsers, blockUsers, unblockUsers); QR code CRUD exists (createQrCode, listQrCodes, getQrCode, updateQrCode, deleteQrCode on PhoneNumberClient); token exchange exists as exchangeBusinessAccessTokenshape-only

Groups map

Groups are a WATS addition with no pywa equivalent. Use them only when you intentionally target the official WhatsApp Business Platform Groups API.

NeedWATS usageStatusNotes
Group entity/webhook types@wats/types/groupsshape-onlyTypes cover group entities and the four group webhook fields.
Graph endpoint callables@wats/graph/endpoints/groups (createGroup, listGroups, resetGroupInviteLink, approveGroupJoinRequests)shape-only; listGroups live-validatedGroups hang off the business phone-number id, not WABA.
Scoped clientsPhoneNumberClient.createGroup, .listGroups, .group(groupId), GroupClientshape-onlyCreate returns request_id; group id and invite link arrive via group_lifecycle_update.
Facade and filtersWhatsApp.sendGroupMessage, WhatsApp.listen({ groupId }), filtersTyped.groupshape-onlyGroup messages carry message.groupId; group statuses carry recipientType: "group".
Service routescreateWatsServiceApp({ enableGroupRoutes: true })shape-onlyRoute opt-in; the default service path is unchanged.
Live mutation validationpendingplannedBlocked on a Groups-entitled test number; see the campaign log.

Webhook, handler, filter, and listener migration

pywa decorators such as @wa.on_message, @wa.on_callback_button, @wa.on_flow_completion, and @wa.on_call_status map to WATS router/listener primitives:

import { WhatsApp, filtersTyped } from "@wats/core";

wa.on(filtersTyped.message.text("hello"), async (update) => {
  await wa.startChat({ to: update.message.from, text: "hi" });
});

Migration differences:

  • WATS TypedRouter.on(...) returns a registration handle with unregister() instead of pywa decorator registration.
  • WATS handlers receive TypedUpdate variants rather than pywa Python classes.
  • WATS wa.listen(...) is the lower-level one-shot listener primitive. For text starts, WhatsApp.startChat(...) returns a waitable result with waitForReply, waitForClick, waitForSelection, waitForFlowCompletion, waitUntilDelivered, waitUntilRead, and waitUntilFailed, all backed by observed webhook updates through the listener registry. Broader send helpers remain planned.
  • WATS filters use function composition (and, or, not, custom) rather than Python &, |, and ~ operators.
  • WATS deep-normalizes common inbound message body families and adds typed filters for media, location, reaction, interactive button/list/nfm Flow-completion replies, and quick-reply buttons. This covers the most common @wa.on_callback_button, @wa.on_callback_selection, and @wa.on_flow_completion migration paths while keeping the update kind as "message".
  • user_preferences maps to TypedUserPreferencesUpdate plus filtersTyped.userPreferences; system maps phone/identity changes to TypedSystemUpdate plus filtersTyped.system; chat_opened maps REQUEST_WELCOME to TypedChatOpenedUpdate plus filtersTyped.chatOpened.
  • WATS still has fewer first-class typed update families than pywa for call permission updates and several status/account details. Calling webhook fields (connect/terminate/status/call_permission_reply) are fully normalized to camelCase, but call_permission_reply remains an interactive message rather than a dedicated TypedUpdate kind.

Error handling migration

pywa raises WhatsAppError subclasses keyed from Graph error codes. WATS exposes GraphApiError subclasses and a seeded registry mirroring pywa error codes where possible:

import { ExpiredAccessTokenError, GraphApiError } from "@wats/graph";

try {
  await phone.sendText({ to: "+155****4567", text: "hello" });
} catch (error) {
  if (error instanceof ExpiredAccessTokenError) {
    // refresh / rotate the token
  } else if (error instanceof GraphApiError) {
    // inspect error.code, error.subcode, error.classification
  }
}

Migration notes:

  • Do not match Python class names in TypeScript code; import WATS subclasses from @wats/graph.
  • WATS validation failures reject before transport with WATS-specific validation errors.
  • Rate-limit retry/backoff is the opt-in createReliableTransport decorator, not automatic. The default GraphClient transport does not retry; opt in explicitly and avoid non-idempotent POST retries unless you also provide idempotency.

Import and subpath cheat sheet

NeedImport
Graph client, scoped clients, endpoints@wats/graph
Mock transport@wats/graph/testing
Message endpoint helpers@wats/graph/endpoints/messages
Media runtime helpers@wats/graph/endpoints/media
Message-template helpers@wats/graph/endpoints/templates
Flow management helpers@wats/graph/endpoints/flows
Calling endpoint helpers@wats/graph/endpoints/calling
Business-management read/admin helpers (getPhoneNumberInfo, blockUsers, submitDisplayNameForReview)@wats/graph/endpoints/business-management
Groups API helpers (createGroup, listGroups, resetGroupInviteLink, approveGroupJoinRequests) — WATS addition, no pywa equivalent@wats/graph/endpoints/groups
Facade/router/filters/listeners@wats/core
Typed filter subpath@wats/core/filtersTyped
Webhook adapters@wats/http or @wats/http/adapters/fetch / bun / node
Config@wats/config
Service app / OpenAPI document@wats/service
CLI testable entry@wats/cli

Known gaps to plan around

Do not migrate code assuming WATS already has pywa parity for:

  • pywa's full decorator and handler taxonomy for callback selections/buttons, Flow request sub-actions, user marketing preferences, phone-number changes, and identity changes.
  • pywa sent-update waiters beyond the currently shipped process-local sent-result/click/selection/Flow-completion waiters.
  • pywa's broad filter catalog: commands, MIME/extension filters, location radius, per-error-code status filters, user marketing filters, and call permission filters.
  • Flow hosting and live Flow data-channel validation.
  • real WebRTC/SIP call orchestration beyond WATS request/webhook shapes and call-button/deep-link helpers.
  • catalog/product inventory CRUD and phone-level callback override. WATS already has profile/commerce updates, phone registration/deregistration, QR code CRUD, token exchange, and WABA callback override as shape-only helpers.
  • full Meta Graph OpenAPI generation and production operator modes beyond the current credential-free wats init, wats doctor, dry-run wats serve, and credential-gated local live wats serve tooling.

Migration checklist

  1. Inventory pywa imports and method names.
  2. Replace constructor setup with GraphClient, PhoneNumberClient, WABAClient, and WhatsApp facade composition.
  3. Convert snake_case names to camelCase.
  4. Move raw secrets to env vars or @wats/config env-secret refs.
  5. Replace pywa send methods with WATS scoped-client methods and root builders.
  6. Replace decorators with TypedRouter.on(...) / WhatsApp.on(...) and filtersTyped.
  7. Replace conversational waits with wa.listen(...) or startChat(...).waitForReply() / status waiters where applicable.
  8. Add MockTransport tests before using real credentials.
  9. Keep live operations behind the campaign gates: read-only before side-effecting before destructive, redact everything.

On this page