feat(cdp): add WhatsApp Cloud API destination#60057
Conversation
Adds a new CDP destination that sends messages via the WhatsApp Cloud API (Meta Graph API). Supports both free-form text messages (within the 24h customer service window) and pre-approved template messages for business-initiated conversations. Auth via bearer access token + phone number ID, mirroring the shape of the existing Twilio SMS destination. Generated-By: PostHog Code Task-Id: 6453e1ec-aa50-45b4-8bf6-364064bbb92e
Replaces the free-form template_language string input with a choice input populated from WhatsApp Cloud API's supported template language codes. Defaults to en_US. Generated-By: PostHog Code Task-Id: 6453e1ec-aa50-45b4-8bf6-364064bbb92e
…uages Adds an optional `searchable` boolean to the choice input schema. When true, the frontend renders LemonSearchableSelect (with a fuzzy-search filter) instead of a plain LemonSelect. Field is optional everywhere (nodejs schema, frontend type, Python validator) so existing templates are unaffected. Sets searchable: true on the WhatsApp template's template_language choice, which has ~70 options. Generated-By: PostHog Code Task-Id: 6453e1ec-aa50-45b4-8bf6-364064bbb92e
Defaults the searchable language dropdown to "Afrikaans" (first alphabetical entry) instead of "en_US". With en_US pre-selected ~17 items down the list, users opened the dropdown, saw a sensible default, and never discovered the field is searchable. Defaulting to the visibly-unusual top entry nudges users to search/scroll on first interaction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
🎭 Playwright didn't run on this PR — your changes touch code that could affect E2E behavior, but Playwright is opt-in via label now to keep CI cost down. Add the Most PRs don't need this. Real regressions still get caught on master and fix-forward. |
|
Size Change: 0 B Total Size: 80.2 MB ℹ️ View Unchanged
|
|
👋 Visual changes detected for this PR. Review and approve in PostHog Visual Review If these changes are unexpected, they may be caused by a flaky test or a broken snapshot on master. Don't approve — rerun the job or wait for a fix. |
Prompt To Fix All With AIFix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
nodejs/src/cdp/templates/_destinations/whatsapp/whatsapp.template.ts:162-163
The `template_language` field defaults to `af` (Afrikaans) simply because it sits first alphabetically. Any user who doesn't notice the language selector will silently send a template with language code `af`, which the WhatsApp Cloud API will reject with a "template language not found" error unless the template was explicitly approved for Afrikaans. The Hog-level fallback (`empty(inputs.template_language) ? 'af' : inputs.template_language`) compounds this by baking the same wrong default into runtime logic. Using `en_US` as the default matches the overwhelming majority of first-time use cases and reduces the chance of a silent send failure.
```suggestion
default: 'en_US',
searchable: true,
```
### Issue 2 of 3
nodejs/src/cdp/templates/_destinations/whatsapp/whatsapp.template.ts:45
The Hog-level fallback for `template_language` hardcodes `af` as the fallback, which should stay in sync with whatever the schema default is. If the default above is changed to `en_US`, this fallback should match.
```suggestion
'code': empty(inputs.template_language) ? 'en_US' : inputs.template_language
```
### Issue 3 of 3
nodejs/src/cdp/templates/_destinations/whatsapp/whatsapp.template.test.ts:114-175
**Prefer parameterised tests for error paths**
The three error-path tests (`missing to_number`, `missing template_name`, and the non-2xx path) are a good candidate for `it.each`, which is the team's stated preference. More importantly, the analogous error case for text mode — an empty `message` with `message_type: 'text'` — is not tested at all, even though the Hog code explicitly throws `'Message body is required for text messages'` for that path. Combining the three error-input cases into a table and adding the missing `message` row would give complete coverage with less boilerplate.
Reviews (1): Last reviewed commit: "Merge branch 'master' into posthog-code/..." | Re-trigger Greptile |
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Query snapshots: Backend query snapshots updatedChanges: 3 snapshots (3 modified, 0 added, 0 deleted) What this means:
Next steps:
|
- Revert template language default to en_US in both the schema and the Hog-level empty fallback. The previous af default was intended to nudge discovery of the searchable dropdown, but it would silently route most template sends to a language code WhatsApp hasn't approved. - Parameterise WhatsApp error-path tests with it.each and add the missing empty-message case for text messages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Also adds the WhatsApp service icon so the CDP destination renders with a visible badge. LemonSelect auto-scrolls to the active item when the menu opens, which pushes the search field offscreen whenever the selected option sits below the visible area. That happens immediately for long lists like the WhatsApp template language picker: opening the menu lands on the active option with the search header scrolled out of view, so users on smaller viewports never discover the field is filterable. Use a CSS :has() rule to sticky-pin the <li> that wraps the search input to the top of the popover's scroll container. Keeps the search visible no matter where the active item is in the list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Global :has() rule would alter the popover behaviour for every LemonSearchableSelect caller, not just the WhatsApp template language picker. Reverting the shared lemon-ui changes — keeping the bundled whatsapp.svg, which already shipped on origin in the prior commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LemonSelect auto-scrolls to the active item when its menu opens, which pushes the search field offscreen whenever the selected option sits past the visible area. Hits the WhatsApp template language picker immediately on first open. Add an opt-in `stickySearchHeader` prop. When true, the search input is wrapped in a marker div and a scoped :has() rule pins the parent <li> to the top of the popover's scroll container. Default is false so existing LemonSearchableSelect callers keep their current behaviour. Enable it for cyclotron-rendered searchable choice inputs (currently just WhatsApp template language). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…bleSelect" This reverts commit ad16d6b.
…or LemonSelect LemonMenu auto-scrolls the active item into view on open, which pushes the LemonSearchableSelect search input offscreen when the selected option sits further down the list (hit immediately by the WhatsApp template language picker). The Popover also flips above the trigger when there isn't enough space below, causing the dropdown to visibly jump between opens. Add two opt-in props on LemonSelect: scrollToActiveOnOpen (forwarded to LemonMenu) and dropdownFallbackPlacements (forwarded to LemonDropdown). LemonSearchableSelect inherits both via its existing prop-spread. Disable both for cyclotron-rendered searchable choice inputs so the search input stays visible on open and the dropdown stays pinned below the trigger. All other callers keep their current behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
Replace LemonSearchableSelect on cyclotron-rendered searchable choice fields (currently just the WhatsApp template-language picker) with the quill Combobox primitive in its `InputInsidePopup` shape: a LemonButton acts as the trigger, ComboboxInput renders inside ComboboxContent so the search field sits outside the scrolling option list and stays visible on open regardless of which option is currently selected. Drop the opt-in scrollToActiveOnOpen and dropdownFallbackPlacements props that were added to LemonSelect/LemonMenu earlier in this PR — they were patching around the bug from the wrong layer. The Combobox primitive solves both the auto-scroll-hiding-the-search and the popover-flip problems by virtue of how Base UI's combobox is structured, so the LemonSelect API stays unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Problem
Customers (notably in markets where WhatsApp is the primary comms channel) want to drive merchant drip campaigns and transactional messages from PostHog Workflows, tied to A/B testing cohorts. Today PostHog has Twilio SMS but no WhatsApp connector, which blocks migrating those drip campaigns onto PostHog.
Refs #35049
Changes
WhatsApp Cloud API destination
Adds a new
template-whatsappCDP destination that posts to the WhatsApp Cloud API (Meta Graph API,graph.facebook.com/<api_version>/<phone_number_id>/messages). Mirrors the shape of the existing Twilio SMS destination so the UX is familiar.access_tokenmarkedsecret: true) +phone_number_idas plain inputs — no new integration/oauth handshake required to ship this first versionmessage_type:text— free-form body, valid within the WhatsApp 24h customer service windowtemplate— pre-approved template name + language code, for business-initiated conversationsv21.0){{ person.properties.phone }}by default) and on the message body, consistent with TwilioLemonSelect: opt-in
scrollToActiveOnOpenanddropdownFallbackPlacementsThe WhatsApp template-language picker uses
LemonSearchableSelectover a list of ~70 languages, which surfaced two pre-existing UX problems inLemonSelect/LemonMenu/ Popover:LemonMenuauto-scrolls the active item into view on open, which pushes the search input (rendered as the first menu item byLemonSearchableSelect) offscreen whenever the selected option sits past the visible area. First-time users had no idea the picker was searchable.flipmiddleware when there isn't enough space below, so the dropdown visibly jumps between above/below depending on viewport position.Adds two opt-in props on
LemonSelect:scrollToActiveOnOpen(forwarded toLemonMenu) — defaults totrue, set tofalseto keep the menu scrolled to the top on open.dropdownFallbackPlacements(forwarded toLemonDropdown→ Popover) — pass[]to disable the flip and pin placement.LemonSearchableSelectinherits both via its existing prop-spread; no API change for direct callers.CyclotronJobInputsopts both off forchoice+searchable: truefields (today, only the WhatsApp template language picker). All otherLemonSelect/LemonSearchableSelectusages keep their current behaviour.How did you test this code?
Publish to changelog?
yes — new WhatsApp destination for CDP / Workflows.
🤖 Agent context
_destinations/twilio/twilio.template.tssince both are messaging destinations with similar input ergonomics. The Twilio template uses theintegrationinput type backed by a stored Twilio account; WhatsApp doesn't have a corresponding PostHog integration yet, so this PR uses plainstringinputs (withsecret: trueon the access token). That keeps the diff small and unblocks the use case; promoting to a proper integration is a follow-up.graph.facebook.com) over the on-prem WhatsApp Business API. It's Meta's recommended/hosted path and the one most users will already have credentials for.textand pre-approvedtemplatepayload shapes. Template support matters because WhatsApp only allows free-form text within the 24h customer service window — business-initiated drip campaigns must use approved templates.toMatchInlineSnapshot, since I couldn't run jest locally to populate snapshots.LemonSelectchanges are deliberately opt-in props rather than behaviour changes. An earlier attempt (stickySearchHeader, since reverted) tried to pin the search header via CSSposition: sticky, but that fought with the auto-scroll-to-active and the popover's overflow boundary. Disabling the scroll-to-active at the source is the cleaner fix and matches the user's mental model (search visible on open, list anchored to the top).Created with PostHog Code