@pixelinfinito/twenty-app-whatsapp
v0.1.2
Published
WhatsApp for Twenty CRM — shared inbox, templates, consent-aware campaigns and delivery health on the Meta WhatsApp Cloud API.
Readme
WhatsApp for Twenty CRM
Two-way WhatsApp messaging inside Twenty: a shared team inbox, conversations on every Person record, approved templates, consent tracking and marketing campaigns — powered by the official Meta WhatsApp Cloud API.
Developed by Marcos Lisboa at Pixel Infinito · published as @pixelinfinito/twenty-app-whatsapp.

What you get
A shared team inbox. Filter by yours, unassigned, everyone's, unread or closed. Assignment, blocking and closing are one click, keyboard navigation included, and every action lands on the contact's timeline.

The 24-hour rule, enforced server-side. The composer always knows whether you may type: window state, consent, blocking, number quality and template availability are decided by the server and explained in the interface — never silently hidden.
Templates you can actually use. Sync from Meta, see which are approved, publish the ones the CRM may render, and fill their parameters from CRM fields with a live preview before sending.

Campaigns with brakes. Build an audience from a saved view, bind template parameters per recipient, get a cost estimate before launch — while a share of the daily tier is held back for 1:1 traffic and a circuit breaker pauses any campaign whose failure rate climbs.

Consent that holds up. Opt-out and opt-in keywords, a single confirmation reply whose wording is a setting rather than a deploy, and an audit event recorded for every change.
Operations you can see. A health panel that names which of six things is wrong and what to do about it, the exact callback URL and webhook fields to paste into Meta, and diagnostics for failed deliveries and stuck sends.
Everything WhatsApp sends, handled. Text, images, audio, video, documents, stickers, locations, contact cards, reactions and replies all render; anything Meta invents next is stored and shown as an unsupported message rather than dropped.
The interface ships in English and Portuguese, following each user's locale.
Requirements
- A Twenty workspace (cloud or self-hosted) whose public URL is reachable over HTTPS — Meta must be able to call your webhook.
- A Meta Business account with an approved WhatsApp Business phone number.
- A Meta app with the WhatsApp product and a system-user access token.
Installation
Install from Settings → Apps in your Twenty workspace, then work through the steps below in order. The order matters: you cannot test the proxy alias before the route exists, and the route does not exist before the app is claimed.
The three silent failures, first
Three setup steps produce a webhook that is configured perfectly in Meta, verifies successfully, and still delivers nothing. Each has its own status code, none is guessable, and Meta reports only an undetailed delivery failure. They are steps 1–3 below.
| If POST <base>/webhooks/server/bb76f114-… answers | Cause | Fix |
| --- | --- | --- |
| 404 LOGIC_FUNCTION_NOT_FOUND | The app registration is unclaimed, so its server route has no workspace to run in | Step 1 |
| 403 LOGIC_FUNCTION_DISABLED | The server refuses to run any logic function | Step 2 |
| 500 MissingConfigError | META_APP_SECRET is unset; the resolver fails closed rather than verify against an empty key | Step 3 |
| 401 {"error":"invalid signature"} | Correct. The route is live and refusing an unsigned request | — |
The health panel's Webhook route (POST) row runs exactly this probe and names whichever of these it finds, so you can check it at any point rather than guessing.
1. Claim the app registration
Open Settings → Applications → WhatsApp → Danger zone and click Claim ownership.
The webhook's POST half is a server route, and a server route runs in the workspace that owns the application registration. While the registration is unclaimed there is no such workspace, so the platform has nowhere to dispatch it and the route does not exist. Everything else looks fine when this is wrong — the app is installed, all its logic functions are registered, the settings page renders.
If you don't see the button, the claim control only renders on the admin panel route:
<base>/settings/admin-panel/applications/registrations/<registrationId>. It requires a signed-in user with full admin-panel access — an API key cannot do it.
2. Enable logic functions on the server
Set this in the Twenty server's environment (not an app variable) and restart:
LOGIC_FUNCTION_TYPE=LOCALLAMBDA is the alternative if you run functions on AWS. With neither, Twenty loads a driver that refuses every logic function — no routes, no scheduled jobs, nothing in this app works.
3. Create and configure a Meta app
- In Meta for Developers, create an app of type Business and add the WhatsApp product.
- From App Dashboard → Settings → Basic, copy the App ID and App Secret.
4. Create credentials
- In Business Manager → System Users, create a system user with access to your WhatsApp Business Account.
- Generate a long-lived token with exactly these scopes:
whatsapp_business_managementwhatsapp_business_messaging
- Generate a random verify token (for example
openssl rand -hex 32).
5. Add the secrets in Twenty
Open Settings → Apps → WhatsApp → Settings → Variables and set all four:
| Variable | Value |
| --- | --- |
| META_APP_ID | App ID from step 3 |
| META_APP_SECRET | App Secret from step 3 |
| META_ACCESS_TOKEN | System-user token from step 4 |
| META_VERIFY_TOKEN | Your random verify token |
All four are required. The resolver fails closed on a missing META_APP_SECRET rather than verifying every signature against an empty key — which is correct, and shows up only as a 500.
6. Configure the webhook in Meta
Meta takes one callback URL and uses it for two things: a GET carrying hub.challenge to verify the endpoint, and POST to deliver events. Twenty answers those on two different paths:
| | Path | Answered by |
| --- | --- | --- |
| GET verification | <base>/s/whatsapp/verify | wa-webhook-verify |
| POST events | <base>/webhooks/server/bb76f114-7843-4a09-af64-9ceca78479cd | wa-webhook-resolver |
So add a one-line alias to the reverse proxy in front of Twenty that method-splits a single public path. Caddy:
example.com {
@wa_verify { path /whatsapp/webhook
method GET }
@wa_events { path /whatsapp/webhook
method POST }
handle @wa_verify {
rewrite * /s/whatsapp/verify?{query}
reverse_proxy twenty:3000
}
handle @wa_events {
rewrite * /webhooks/server/bb76f114-7843-4a09-af64-9ceca78479cd
reverse_proxy twenty:3000
}
handle { reverse_proxy twenty:3000 }
}Nginx:
location = /whatsapp/webhook {
if ($request_method = GET) { rewrite ^ /s/whatsapp/verify?$args last; }
if ($request_method = POST) { rewrite ^ /webhooks/server/bb76f114-7843-4a09-af64-9ceca78479cd last; }
return 405;
}Cloudflare (no origin config needed — two URL Rewrite Transform Rules, under Rules → Overview → Create rule → URL Rewrite Rule). Your DNS record must be proxied:
Rule 1 — verification
(http.host eq "crm.example.com" and http.request.uri.path eq "/whatsapp/webhook"
and http.request.method eq "GET")
Path → Static → /s/whatsapp/verify Query → leave unchanged
Rule 2 — events
(http.host eq "crm.example.com" and http.request.uri.path eq "/whatsapp/webhook"
and http.request.method eq "POST")
Path → Static → /webhooks/server/bb76f114-7843-4a09-af64-9ceca78479cdRewrite only the path in both, so hub.mode / hub.challenge / hub.verify_token survive on the GET. The two are mutually exclusive by method, so order does not matter. In the expression builder http.request.method appears as Request Method.
Turn off bot protection for this host. Cloudflare's AI Labyrinth and Bot Fight Mode classify Meta's webhook deliveries as crawler traffic and serve them decoy content, so they never reach Twenty. This is the worst failure mode in the whole setup, because it leaves no trace on your server at all: verification passes, the route answers every manual test correctly, and real deliveries vanish with nothing in any log. The only evidence is in Cloudflare → Security → Events, where the deliveries appear as
AI Labyrinth Served.Turn both off under Security → Settings → Bot traffic, or add a WAF custom rule on
/whatsapp/webhookwith the Skip action and every bot option selected. On a CRM host there is no content for an AI crawler to scrape, so the protection costs more than it earns.
Do not let the proxy buffer, re-encode or otherwise rewrite the request body: the HMAC is computed over the exact bytes Meta sent, and any normalisation invalidates every signature.
Then open Webhooks in the Meta app's WhatsApp product, set the callback to <your base URL>/whatsapp/webhook, paste the same verify token, and click Verify and save.
Can you skip the proxy? Only if your Twenty version routes
GETto server routes. It does not on 2.31.x — verified on 2.31.1 and 2.31.6, where theGETreturns the front-end shell instead of the challenge. Check yours withcurl "<base>/webhooks/server/bb76f114-7843-4a09-af64-9ceca78479cd?hub.mode=subscribe&hub.challenge=probe&hub.verify_token=<your token>". If it returnsprobe, point Meta straight there. If it returns HTML, you need the alias.
7. Subscribe the webhook fields
Subscribe all seven: messages, message_template_status_update, message_template_quality_update, message_template_components_update, account_update, phone_number_quality_update, business_capability_update.
Without message_template_status_update, template approvals never arrive.
8. Connect your number, then test
In the app settings, connect your WABA and phone number, run the health check, and sync templates.
Connect the number before testing the webhook. Meta's "Send to my server" sample carries a phone_number_id nobody has claimed. The resolver answers it 200 and discards it — deliberately, since a non-2xx would earn a week of Meta retries for a number you do not host — so it leaves no row at all in Diagnostics. Run against an unconnected number, a perfectly working webhook and a broken one look identical.
Once your number is connected, a test delivery appears under Diagnostics, and the health panel verifies each remaining piece.
Troubleshooting
Seeing unverified webhook, signature failed, or no events?
- Nothing in the logs at all? If a real delivery leaves no trace — no
signature_rejected, nounclaimed, no row — then it never reached your server, and the cause is in front of it. On Cloudflare, check Security → Events forAI Labyrinth Servedor a bot challenge on/whatsapp/webhook(see step 6). Silence in your own logs is evidence about the edge, not about the app. - Start with the Webhook route (POST) row in the health panel. It probes the route directly and names the cause — see the table at the top of Installation for what each status means. This is the single most likely explanation of "verification passed but no events ever arrive".
- Is your number connected? An unclaimed number's delivery is accepted and discarded with no row (step 8). Test with a connected number or you cannot tell success from failure.
- Confirm the proxy alias from step 6 exists in both directions. A callback that verifies but delivers nothing is the classic symptom of a
GETrule without itsPOSTcounterpart — thePOSTthen 404s somewhere you will only see in the proxy log. - Re-copy the callback URL and verify token into Meta from the latest save.
- Confirm the endpoint is reachable over HTTPS and not behind an IP/VPC block.
- Confirm all webhook fields above are subscribed.
- Confirm the system-user token still has both WhatsApp scopes.
The health panel diagnoses each of these individually.
Configuration
Beyond the Meta secrets, the app exposes ~30 application variables so operational behavior never requires a deploy — send throttles and pacing, campaign batch sizes and failure thresholds, opt-in/opt-out keywords and confirmation wording (English and Portuguese), retention windows, timeline verbosity, per-category pricing for cost estimates, and more. Each variable is documented in place under Settings → Apps → WhatsApp.
Per-number settings (throttle, default calling code, auto-assignment, contact auto-creation) live on the WhatsApp account record, so numbers can differ.
Development
yarn install
yarn twenty docker:start # local Twenty server
yarn twenty dev # sync the app and watchSee SETUP.md for the full local guide, specs/ for design and architecture notes, and CHANGELOG.md for notable changes. yarn test:unit runs the local test suite; yarn test runs integration tests against a disposable workspace.
Learn more
License
MIT © Pixel Infinito
