wapit

wapit documentation

wapit is a local WhatsApp Cloud API for development: Mailpit, but for WhatsApp. Point your app's Graph API base URL at wapit, keep your real code paths, and see each message in a local inbox that looks like a chat. It is not affiliated with Meta or WhatsApp.

View as Markdown
On this pageInstall

Install

With Homebrew on macOS or Linux:

$ brew install patriciomartinns/tap/wapit

Or run the container. The last two options let wapit deliver webhooks to apps on your machine, such as http://localhost:8000, from inside the container:

$ docker run --rm -p 127.0.0.1:8026:8026 -p 127.0.0.1:8027:8027 \
  --add-host host.docker.internal:host-gateway \
  -e WAPIT_LOOPBACK_HOST=host.docker.internal \
  ghcr.io/patriciomartinns/wapit

Or build from source with Rust 1.89 or newer:

$ cargo install --locked --git https://github.com/patriciomartinns/wapit

On Windows, download wapit-<version>-x86_64-pc-windows-msvc.zip from the latest release, unzip it and run wapit.exe. The same page has prebuilt binaries for macOS and Linux, and a SHA256SUMS file to check them: github.com/patriciomartinns/wapit/releases/latest

Quick start

Run wapit with no configuration and it serves one default app. The inbox is on http://127.0.0.1:8026 and the Graph API on http://127.0.0.1:8027.

SettingValue
Phone number id100000000000001
WhatsApp Business Account id200000000000001
Access tokenwapit-local-token
App secretwapit-local-secret
Webhook URLhttp://localhost:8000/webhook
Webhook verify tokenwapit-verify-token

Set the same values in your app, with http://127.0.0.1:8027 as the Graph API base URL, and send a message:

$ curl http://127.0.0.1:8027/v26.0/100000000000001/messages \
  -H 'Authorization: Bearer wapit-local-token' \
  -H 'Content-Type: application/json' \
  -d '{"messaging_product":"whatsapp","to":"15550000000","type":"text","text":{"body":"Hello"}}'

A free-form message to someone who never wrote to you is accepted and then fails with error 131047, exactly as Meta does outside the 24-hour service window. Reply as the user from the inbox to open the window.

wapit accepts any version in the path, or none. The examples use v26.0, the latest stable Graph API version, and webhooks have the v24.0 and later shape, without the conversation object in statuses.

Configure your apps

Copy config/apps.example.json to config/apps.json to describe your own apps. Each app is one WhatsApp phone number:

FieldMeaning
idShort name, also the name of its templates file
nameName shown in the inbox
phone_number_id, display_phone_number, waba_idThe ids your app uses in Graph API paths
access_tokenBearer token your app sends
app_secretSecret used to sign webhooks in X-Hub-Signature-256
webhook_urlWhere wapit posts messages and statuses
verify_tokenOptional. Lets the inbox run Meta's hub.challenge verification against your app
auto_statusesStatuses fired after an accepted send. Defaults to ["sent"]
syncOptional. Where wapit sync reads real templates from

The file reloads when it changes. If an edit breaks it, wapit keeps the last good version and shows the error in the inbox.

Templates

Templates live in config/templates/<app id>.json, in the format Meta's message_templates endpoint returns. Your app can also create, edit and delete them through the Graph API, and they are approved right away.

Copy them from your real account, read-only, with:

$ WAPIT_SYNC_TOKEN=... wapit sync <app id>

It reads Graph API v26.0 unless the app's sync.graph_version says otherwise. Synced templates keep Meta's ids, so POST /{template_id} and hsm_id use the same ids as your real account. This is the only command that talks to Meta, and only when you run it.

Environment

VariableDefaultMeaning
WAPIT_CONFIG_DIR./configWhere apps.json and templates/ live
WAPIT_DATA_DIR./dataSQLite database and media files
WAPIT_HOST127.0.0.1Address both servers bind to
WAPIT_API_PORT, WAPIT_UI_PORT8027, 8026Ports
WAPIT_PUBLIC_API_URLhttp://127.0.0.1:8027Base of the media download URLs given to apps
WAPIT_PUBLIC_UI_URLhttp://127.0.0.1:8026Where a browser opening the API root is sent
WAPIT_LOOPBACK_HOSTHost that receives webhooks whose host resolves to loopback. Set it to host.docker.internal in a container
WAPIT_CA_CERTSPEM file of extra certificate authorities trusted for webhooks, such as a local development CA
WAPIT_ALLOWED_HOSTSExtra host names the servers answer to, comma separated
WAPIT_LOGinfoLog filter, e.g. wapit=debug

An app running in another container must reach wapit by a name that resolves there, such as host.docker.internal:8027. Media URLs follow WAPIT_PUBLIC_API_URL, so set it to that same address.

Graph API coverage

EndpointBehavior
POST /{phone_number_id}/messagesText, template, interactive (reply buttons, list, call to action URL, flow, single and multi product, catalog, call permission request), media, reaction, location, contacts. status: read, with an optional typing indicator
POST /{phone_number_id}/mediaUploads a file and returns its id. Only Meta's supported types are accepted, up to 5 MB for images, 16 MB for audio and video, 100 MB for documents and 500 KB for stickers. Meta allows 500 KB for an animated sticker and 100 KB for a static one; wapit cannot tell them apart, so every sticker gets 500 KB
GET /{media_id}, DELETE /{media_id}Media metadata with a download URL, and deletion
GET /{waba_id}/message_templatesThe local templates, filtered by name, status, category and language, and paged with limit, after and before
POST /{waba_id}/message_templatesCreates a template, approved right away
POST /{template_id}Edits a template's components or category. Only approved, rejected or paused templates can be edited, and an approved one keeps its category
DELETE /{waba_id}/message_templates?name=Deletes a template, one language of it with hsm_id, or up to 100 templates at once with hsm_ids, all or nothing. Disabled templates cannot be deleted
GET /{waba_id}, GET /{phone_number_id}Account and phone number fields, with an empty pricing analytics result

A reaction gets only the sent status, as on Meta, and a reaction to a message that is not in the chat, is itself a reaction or is more than 30 days old is accepted and then fails with error 131009.

Business-scoped user IDs

Send with recipient set to a business-scoped user ID (BSUID), such as US.13491208655302741918, instead of to, and the response names the contact as user_id instead of wa_id. When a send has both, to wins, as on Meta. Webhooks have the shape Meta has used since April 2026: contacts[].user_id, messages[].from_user_id and statuses[].recipient_user_id are always there, and wa_id, from and recipient_id only in phone number conversations. wapit derives a stable BSUID for each phone number from the number and the WhatsApp Business Account id. A send whose recipient is that derived BSUID goes to the phone number's conversation, inside its service window. One-tap, zero-tap and copy code authentication templates cannot be sent to a BSUID (131062). Group recipients (recipient_type: group) are not supported.

Error codes

Sends are checked like Meta checks them. A rejected send returns Meta's error to your app and stays in the inbox, flagged, with the reason in plain words.

CodeWhen
132000Parameter count in the header, body or URL buttons does not match the template
132001Template name or language does not exist
132005Text too long after the variables are filled in
132012Header format does not match the template
132015Template is paused
132016Template is disabled
132018Line breaks, tabs or more than 4 spaces in a row in a body parameter
131008A parameter is missing its text
131009A value breaks Meta's rules: a field longer than its limit, duplicate button ids or titles, or a button index or type that does not match the template
131047Free-form message outside the 24-hour service window
131053An upload is larger than its type allows, or an uploaded file is sent as a message type it does not belong to
131062An authentication template that needs a phone number is sent to a BSUID

Acting as the user

In a conversation you can type a message, attach a file, tap a reply, quick reply or list button, or select one of your app's messages to mark it delivered, read, played (voice messages only) or failed with a real Meta error code. Every action is posted to the app's webhook in Meta's payload format, signed with the app secret in X-Hub-Signature-256. As on Meta, the pricing object comes with the sent status and with the first of delivered or read, never with both.

The inspector shows the payload your app sent, each webhook delivery with your app's response, and lets you send a webhook again. Apps with a verify_token can also run the subscription handshake from the inbox.

wapit delivers each webhook once. Unlike Meta, which retries a failed delivery, it does not retry on its own: if your app is down or answers with an error, the delivery is marked failed in the inspector and you send it again from there.

Security

wapit has no authentication by design: it is a development tool for your own machine.

Do not publish its ports beyond your machine. Report vulnerabilities privately through GitHub: github.com/patriciomartinns/wapit/security