Green API stopgap (unofficial transport)
Status: temporary. Exists only because Meta Business Verification has been pending since ~2026-08-01, blocking Access Verification → App Review → the Cloud API go-live. Delete this transport once Meta approves.
What it is
green-api.com is an unofficial WhatsApp gateway: it runs a headless WhatsApp Web client on their servers and links it to a number as a linked device — the same slot a laptop takes. Meta never sees an API consumer, which is exactly why it needs no approval, no Business Verification, no App Review, no template review, and has no 24-hour service window.
It is also, unambiguously, a WhatsApp ToS violation. The connected number can be banned — permanently, per Green’s own docs (“inability to use the number”, from “systematic use of unofficial clients”). That risk lands on the gym, not on Taikan.
Risk controls built into the code
These are not optional polish; they are the reason the integration is survivable.
| Control | Where | Why |
|---|---|---|
| Instance-level send pacing, default 15s between messages | GreenApiConfigService.sendDelayMs, pushed to the instance at provision time as delaySendMessagesMilliseconds | Green’s anti-ban guidance. Their floor is 500ms; we default to the safe end. Pacing is enforced by their queue, so the worker doesn’t need a per-connection limiter. |
| Broadcast cap of 200 recipients/day | GREEN_API_DAILY_RECIPIENT_CAP, enforced in WhatsAppBroadcastsService.process | Green’s documented ceiling for a warmed number. Overflow is counted as skipped and logged — never silently truncated. |
Per-org PostHog flag whatsapp-green-transport | GreenConnectService.isConfigured | The env vars are a global switch; this keeps the QR card invisible to every org except the ones deliberately opted in. Default off, fail-off. |
| Webhook fails closed | GreenWebhookController.authorized | Green signs nothing (no HMAC equivalent of X-Hub-Signature-256). The only auth is the webhookUrlToken it echoes as Authorization: Bearer. Unset secret ⇒ reject everything. Constant-time compare. |
| API token never logged | GreenApiService.describe | The token is a path segment in every Green URL, so no raw URL may reach a log line or error message. |
| Instance teardown on disconnect | GreenConnectService.disconnect | Instances bill daily from creation. A failed deleteInstanceAccount is logged at error because it leaks money. |
| STOP keywords shared with the Cloud path | STOP_KEYWORDS exported from whatsapp-ingestion.service.ts | An opt-out that works on one transport and not the other is a compliance bug (Israeli Amendment 40 covers WhatsApp explicitly). |
How it differs from the Cloud API path
Cloud API (whatsapp_cloud) | Green (whatsapp_unofficial) | |
|---|---|---|
| Connect | Embedded Signup v4 popup | Partner createInstance → QR scan → poll getStateInstance |
| Credentials | { accessToken } (BISU) | { idInstance, apiTokenInstance } |
externalAccountId | phone_number_id | idInstance |
| Templates | Submitted to Meta, approved by review | Local only, stored approved, rendered to plain text by renderTemplateText |
| 24h window | Enforced; free-form only inside it | None — WhatsAppWindowService returns true, so automations always send the real body |
| Message id | wamid | Green’s idMessage (same column) |
| Delivery statuses | messages webhook | outgoingMessageStatus webhook, mapped onto the same applyStatus |
Everything downstream of enqueueSend is shared: the whatsapp_messages ledger, the BullMQ queue, broadcasts, notification prefs, lead consent, and the conversations layer.
Precedence during the cutover
WhatsAppSenderService.getActiveConnection prefers whatsapp_cloud whenever both connections are active. The day Meta approves and the org completes Embedded Signup, sending moves to the official transport on its own — no data migration, no flag flip, no code change.
Environment
GREEN_API_PARTNER_TOKEN= # from [email protected]; bills daily per instance
GREEN_API_WEBHOOK_BASE_URL= # public API host (locally: cloudflared tunnel)
GREEN_API_WEBHOOK_TOKEN= # shared secret; webhook fails closed without it
GREEN_API_SEND_DELAY_MS=15000 # anti-ban pacing, range 500..600000
GREEN_API_BASE_URL= # optional host overridePlus the per-org flag whatsapp-green-transport, and whatsapp-messaging for sending as usual.
Code map
| Path | Role |
|---|---|
apps/api/src/whatsapp/green-api.service.ts | REST client (send, QR, state, settings, partner provisioning) + GreenApiError |
apps/api/src/whatsapp/green-api.config.service.ts | Env reader |
apps/api/src/lead-ingestion/green-connect.service.ts | Provision → QR → state → disconnect |
apps/api/src/lead-ingestion/green-integration.controller.ts | Authed org-scoped connect endpoints |
apps/api/src/lead-ingestion/green-webhook.controller.ts | POST /webhooks/green/:connectionId, bearer-authenticated |
apps/api/src/lead-ingestion/green-ingestion.service.ts | Inbound STOP handling |
apps/api/src/lead-ingestion/green.types.ts | Webhook payload shapes |
apps/web/src/components/settings/whatsapp-green-connect.tsx | QR pairing card |
Green-specific branches inside shared files are marked with a comment naming the transport: whatsapp-sender.service.ts (buildGreenRequestBody), whatsapp-send.processor.ts (deliverViaGreen), whatsapp-events.service.ts (the Green notifications section), whatsapp-templates.service.ts (greenContext), whatsapp-window.service.ts, whatsapp-broadcasts.service.ts.
Removal checklist (when Meta approves)
- Turn the
whatsapp-green-transportflag off for every org; confirm each gym has completed Embedded Signup. - Disconnect each Green connection through the UI (this deletes the billed instance).
- Delete the files in the code map above, plus this doc.
- Remove the Green branches in the shared files listed above and the
whatsapp_unofficialhandling ingetActiveConnection. - Drop the
GREEN_API_*env vars.
The whatsapp_unofficial value stays in the integration_provider enum — it predates this work (migration 0068) and removing a Postgres enum value is not worth a migration.