whatsapp integration

This commit is contained in:
debu-2 2026-10-02 12:51:46 +05:30
parent d077292bbc
commit 141ca9fda3
15 changed files with 578 additions and 1 deletions

View file

@ -37,3 +37,13 @@ BONVOICE_API_URL=https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBri
BONVOICE_MEDIA_ALLOWLIST=103.131.25.5 BONVOICE_MEDIA_ALLOWLIST=103.131.25.5
# Public WSS base; Bonvoice media endpoint = {VOICE_RELAY_PUBLIC_URL}/ws/media # Public WSS base; Bonvoice media endpoint = {VOICE_RELAY_PUBLIC_URL}/ws/media
VOICE_RELAY_PUBLIC_URL=ws://localhost:8000/api/v1/voice VOICE_RELAY_PUBLIC_URL=ws://localhost:8000/api/v1/voice
# --- WhatsApp Business Platform (Meta tech provider / BSP) ---
PUBLIC_API_BASE_URL=http://localhost:8000
WHATSAPP_VERIFY_TOKEN=replace-with-a-long-random-string
WHATSAPP_APP_SECRET=
WHATSAPP_ACCESS_TOKEN=
WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_BUSINESS_ACCOUNT_ID=
META_APP_ID=
WHATSAPP_API_VERSION=v21.0

View file

@ -48,3 +48,20 @@ BONVOICE_MEDIA_ALLOWLIST=103.131.25.5
VOICE_RELAY_PUBLIC_URL=ws://localhost:8000/api/v1/voice VOICE_RELAY_PUBLIC_URL=ws://localhost:8000/api/v1/voice
# Production: VOICE_RELAY_PUBLIC_URL=wss://api.urmiai.com/api/v1/voice # Production: VOICE_RELAY_PUBLIC_URL=wss://api.urmiai.com/api/v1/voice
# Bonvoice portal voicebot endpoint: wss://api.urmiai.com/api/v1/voice/ws/media # Bonvoice portal voicebot endpoint: wss://api.urmiai.com/api/v1/voice/ws/media
# --- WhatsApp Business Platform (Meta tech provider / BSP) ---
# Public HTTPS API base — used to build the webhook callback URL shown in console
PUBLIC_API_BASE_URL=https://api.urmiai.com
# Callback verify token — paste the SAME value in Meta → Configure Webhooks → Verify token
WHATSAPP_VERIFY_TOKEN=replace-with-a-long-random-string
# App secret from Meta App Dashboard → App settings → Basic
WHATSAPP_APP_SECRET=
# System user or long-lived token with whatsapp_business_messaging + whatsapp_business_management
WHATSAPP_ACCESS_TOKEN=
# From Meta → WhatsApp → API Setup (Phone number ID)
WHATSAPP_PHONE_NUMBER_ID=
# WhatsApp Business Account ID (WABA)
WHATSAPP_BUSINESS_ACCOUNT_ID=
# Meta App ID
META_APP_ID=
WHATSAPP_API_VERSION=v21.0

View file

@ -30,6 +30,15 @@ class Settings(BaseSettings):
bonvoice_api_url: str = "https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/" bonvoice_api_url: str = "https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/"
bonvoice_media_allowlist: str = "103.131.25.5" bonvoice_media_allowlist: str = "103.131.25.5"
voice_session_ttl_seconds: int = 600 voice_session_ttl_seconds: int = 600
whatsapp_api_version: str = "v21.0"
whatsapp_verify_token: str = ""
whatsapp_app_secret: str = ""
whatsapp_access_token: str = ""
whatsapp_phone_number_id: str = ""
whatsapp_business_account_id: str = ""
meta_app_id: str = ""
# Public HTTPS base for webhook callback (no trailing slash), e.g. https://api.urmiai.com
public_api_base_url: str = ""
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=(".env", "../.env"), env_file=(".env", "../.env"),
@ -46,4 +55,13 @@ class Settings(BaseSettings):
return [self.frontend_origin] return [self.frontend_origin]
@computed_field # type: ignore[prop-decorator]
@property
def whatsapp_webhook_url(self) -> str:
base = self.public_api_base_url.rstrip("/") if self.public_api_base_url else ""
if base:
return f"{base}/api/v1/whatsapp/webhook"
return "/api/v1/whatsapp/webhook"
settings = Settings() settings = Settings()

View file

@ -25,6 +25,7 @@ from .routes_integrations import router as integrations_router
from .routes_numbers import router as numbers_router from .routes_numbers import router as numbers_router
from .routes_urmiotp import router as urmiotp_router from .routes_urmiotp import router as urmiotp_router
from .routes_voice import router as voice_router from .routes_voice import router as voice_router
from .routes_whatsapp import router as whatsapp_router
@asynccontextmanager @asynccontextmanager
@ -86,6 +87,7 @@ app.include_router(campaigns_router)
app.include_router(numbers_router) app.include_router(numbers_router)
app.include_router(integrations_router) app.include_router(integrations_router)
app.include_router(voice_router) app.include_router(voice_router)
app.include_router(whatsapp_router)
app.include_router(admin_router) app.include_router(admin_router)

View file

@ -491,3 +491,20 @@ class OtpMessageLog(Base):
error_message: Mapped[str | None] = mapped_column(String(500), nullable=True) error_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
user: Mapped[UserAccount] = relationship(back_populates="otp_message_logs") user: Mapped[UserAccount] = relationship(back_populates="otp_message_logs")
class WhatsAppWebhookEvent(Base):
"""Inbound webhook payloads from Meta WhatsApp Business Platform."""
__tablename__ = "whatsapp_webhook_events"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
waba_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
phone_number_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
field: Mapped[str] = mapped_column(String(80), index=True)
event_type: Mapped[str] = mapped_column(String(80), index=True)
from_wa_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
message_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
status: Mapped[str | None] = mapped_column(String(32), nullable=True)
payload: Mapped[dict] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)

View file

@ -0,0 +1,144 @@
import json
import logging
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from .auth import current_user
from .config import settings
from .database import get_db
from .models import UserAccount, WhatsAppWebhookEvent
from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError
from .whatsapp_security import verify_meta_signature
from .whatsapp_webhook import persist_webhook_payload
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/whatsapp", tags=["whatsapp"])
RECOMMENDED_WEBHOOK_FIELDS = [
"messages",
"message_template_status_update",
"message_template_quality_update",
"account_update",
"phone_number_name_update",
"phone_number_quality_update",
"business_capability_update",
]
def _serialize_event(event: WhatsAppWebhookEvent) -> dict:
return {
"id": str(event.id),
"waba_id": event.waba_id,
"phone_number_id": event.phone_number_id,
"field": event.field,
"event_type": event.event_type,
"from_wa_id": event.from_wa_id,
"message_id": event.message_id,
"status": event.status,
"created_at": event.created_at,
}
@router.get("/webhook")
def verify_webhook(
hub_mode: str | None = Query(default=None, alias="hub.mode"),
hub_verify_token: str | None = Query(default=None, alias="hub.verify_token"),
hub_challenge: str | None = Query(default=None, alias="hub.challenge"),
) -> Response:
"""
Meta webhook verification (Step 2 — Configure Webhooks).
Meta sends hub.mode=subscribe, hub.verify_token, and hub.challenge.
"""
if not settings.whatsapp_verify_token:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="WHATSAPP_VERIFY_TOKEN is not configured",
)
if hub_mode == "subscribe" and hub_verify_token == settings.whatsapp_verify_token:
logger.info("WhatsApp webhook verified by Meta")
return Response(content=hub_challenge or "", media_type="text/plain")
logger.warning("WhatsApp webhook verification failed mode=%s", hub_mode)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Verification failed")
@router.post("/webhook")
async def receive_webhook(
request: Request,
db: Session = Depends(get_db),
x_hub_signature_256: str | None = Header(default=None, alias="X-Hub-Signature-256"),
) -> dict[str, str]:
"""Receive inbound WhatsApp Business Platform webhook payloads."""
raw_body = await request.body()
if settings.whatsapp_app_secret:
if not verify_meta_signature(settings.whatsapp_app_secret, raw_body, x_hub_signature_256):
logger.warning("WhatsApp webhook signature validation failed")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid signature")
elif settings.environment == "production":
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="WHATSAPP_APP_SECRET is required in production",
)
try:
payload = json.loads(raw_body.decode("utf-8") or "{}")
except (UnicodeDecodeError, json.JSONDecodeError):
payload = {}
persist_webhook_payload(db, payload)
return {"status": "ok"}
@router.get("/setup")
def whatsapp_setup(
_: UserAccount = Depends(current_user),
) -> dict:
"""Console setup info — paste these values into Meta Developer Console."""
return {
"webhook_url": settings.whatsapp_webhook_url,
"verify_token_configured": bool(settings.whatsapp_verify_token),
"app_secret_configured": bool(settings.whatsapp_app_secret),
"access_token_configured": bool(settings.whatsapp_access_token),
"phone_number_id": settings.whatsapp_phone_number_id or None,
"business_account_id": settings.whatsapp_business_account_id or None,
"meta_app_id": settings.meta_app_id or None,
"api_version": settings.whatsapp_api_version,
"recommended_fields": RECOMMENDED_WEBHOOK_FIELDS,
"docs_url": "https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview/",
}
@router.get("/events")
def list_webhook_events(
limit: int = Query(default=25, ge=1, le=100),
db: Session = Depends(get_db),
_: UserAccount = Depends(current_user),
) -> list[dict]:
events = db.scalars(
select(WhatsAppWebhookEvent)
.order_by(WhatsAppWebhookEvent.created_at.desc())
.limit(limit)
).all()
return [_serialize_event(event) for event in events]
class SendWhatsAppTextRequest(BaseModel):
to: str = Field(min_length=8, max_length=20)
body: str = Field(min_length=1, max_length=4096)
@router.post("/messages/send")
async def send_whatsapp_text(
payload: SendWhatsAppTextRequest,
_: UserAccount = Depends(current_user),
) -> dict:
"""Send a test text message via Graph API (requires WHATSAPP_ACCESS_TOKEN)."""
client = WhatsAppGraphClient()
try:
return await client.send_text(payload.to.strip(), payload.body.strip())
except WhatsAppGraphError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc

View file

@ -0,0 +1,56 @@
import httpx
from .config import settings
class WhatsAppGraphError(Exception):
def __init__(self, detail: str, status_code: int = 502) -> None:
super().__init__(detail)
self.detail = detail
self.status_code = status_code
class WhatsAppGraphClient:
def __init__(
self,
*,
access_token: str | None = None,
phone_number_id: str | None = None,
api_version: str | None = None,
) -> None:
self.access_token = access_token or settings.whatsapp_access_token
self.phone_number_id = phone_number_id or settings.whatsapp_phone_number_id
self.api_version = api_version or settings.whatsapp_api_version
@property
def configured(self) -> bool:
return bool(self.access_token and self.phone_number_id)
def _messages_url(self) -> str:
return (
f"https://graph.facebook.com/{self.api_version}/"
f"{self.phone_number_id}/messages"
)
async def send_text(self, to: str, body: str) -> dict:
if not self.configured:
raise WhatsAppGraphError("WhatsApp Graph API is not configured", status_code=503)
payload = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": to,
"type": "text",
"text": {"preview_url": False, "body": body},
}
headers = {"Authorization": f"Bearer {self.access_token}"}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(self._messages_url(), json=payload, headers=headers)
try:
data = response.json()
except ValueError:
data = {"raw": response.text}
if response.status_code >= 400:
detail = data.get("error", {}).get("message") if isinstance(data, dict) else response.text
raise WhatsAppGraphError(detail or "WhatsApp Graph API request failed", status_code=response.status_code)
return data

View file

@ -0,0 +1,13 @@
import hashlib
import hmac
def verify_meta_signature(app_secret: str, raw_body: bytes, signature_header: str | None) -> bool:
"""Validate X-Hub-Signature-256 from Meta webhook POSTs."""
if not app_secret or not signature_header:
return False
if not signature_header.startswith("sha256="):
return False
expected = signature_header.removeprefix("sha256=")
computed = hmac.new(app_secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(computed, expected)

View file

@ -0,0 +1,97 @@
import logging
from typing import Any
from sqlalchemy.orm import Session
from .models import WhatsAppWebhookEvent
logger = logging.getLogger(__name__)
def _extract_events(payload: dict[str, Any]) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
if payload.get("object") != "whatsapp_business_account":
return events
for entry in payload.get("entry", []):
waba_id = entry.get("id")
for change in entry.get("changes", []):
field = change.get("field", "unknown")
value = change.get("value", {})
phone_number_id = (value.get("metadata") or {}).get("phone_number_id")
if field == "messages":
for message in value.get("messages", []):
events.append(
{
"waba_id": waba_id,
"phone_number_id": phone_number_id,
"field": field,
"event_type": "message",
"from_wa_id": message.get("from"),
"message_id": message.get("id"),
"status": message.get("type"),
"payload": change,
}
)
for status in value.get("statuses", []):
events.append(
{
"waba_id": waba_id,
"phone_number_id": phone_number_id,
"field": field,
"event_type": "status",
"from_wa_id": status.get("recipient_id"),
"message_id": status.get("id"),
"status": status.get("status"),
"payload": change,
}
)
else:
events.append(
{
"waba_id": waba_id,
"phone_number_id": phone_number_id,
"field": field,
"event_type": field,
"from_wa_id": None,
"message_id": None,
"status": None,
"payload": change,
}
)
return events
def persist_webhook_payload(db: Session, payload: dict[str, Any]) -> int:
events = _extract_events(payload)
if not events:
events = [
{
"waba_id": None,
"phone_number_id": None,
"field": "unknown",
"event_type": "raw",
"from_wa_id": None,
"message_id": None,
"status": None,
"payload": payload,
}
]
for event in events:
db.add(
WhatsAppWebhookEvent(
waba_id=event.get("waba_id"),
phone_number_id=event.get("phone_number_id"),
field=event["field"],
event_type=event["event_type"],
from_wa_id=event.get("from_wa_id"),
message_id=event.get("message_id"),
status=event.get("status"),
payload=event["payload"],
)
)
db.commit()
logger.info("Stored %s WhatsApp webhook event(s)", len(events))
return len(events)

View file

@ -44,6 +44,14 @@ services:
BONVOICE_API_URL: ${BONVOICE_API_URL:-https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/} BONVOICE_API_URL: ${BONVOICE_API_URL:-https://backend.pbx.bonvoice.com/autoDialManagement/autoCallBridging/}
BONVOICE_MEDIA_ALLOWLIST: ${BONVOICE_MEDIA_ALLOWLIST:-103.131.25.5} BONVOICE_MEDIA_ALLOWLIST: ${BONVOICE_MEDIA_ALLOWLIST:-103.131.25.5}
VOICE_RELAY_PUBLIC_URL: ${VOICE_RELAY_PUBLIC_URL:-ws://localhost:8000/api/v1/voice} VOICE_RELAY_PUBLIC_URL: ${VOICE_RELAY_PUBLIC_URL:-ws://localhost:8000/api/v1/voice}
PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-}
WHATSAPP_VERIFY_TOKEN: ${WHATSAPP_VERIFY_TOKEN:-}
WHATSAPP_APP_SECRET: ${WHATSAPP_APP_SECRET:-}
WHATSAPP_ACCESS_TOKEN: ${WHATSAPP_ACCESS_TOKEN:-}
WHATSAPP_PHONE_NUMBER_ID: ${WHATSAPP_PHONE_NUMBER_ID:-}
WHATSAPP_BUSINESS_ACCOUNT_ID: ${WHATSAPP_BUSINESS_ACCOUNT_ID:-}
META_APP_ID: ${META_APP_ID:-}
WHATSAPP_API_VERSION: ${WHATSAPP_API_VERSION:-v21.0}
UPLOAD_DIR: uploads UPLOAD_DIR: uploads
KYC_TEMPLATE_PATH: assets/kyc-template.docx KYC_TEMPLATE_PATH: assets/kyc-template.docx
volumes: volumes:

View file

@ -18,6 +18,7 @@ import { VoicePage } from './features/voice/VoicePage'
import { TestingCenterPage } from './features/testing/TestingCenterPage' import { TestingCenterPage } from './features/testing/TestingCenterPage'
import { LogsPage } from './features/logs/LogsPage' import { LogsPage } from './features/logs/LogsPage'
import { AnalyticsPage } from './features/analytics/AnalyticsPage' import { AnalyticsPage } from './features/analytics/AnalyticsPage'
import { WhatsAppSetupPage } from './features/whatsapp/WhatsAppSetupPage'
import { UrmiOtpPage } from './features/agent/UrmiOtpPage' import { UrmiOtpPage } from './features/agent/UrmiOtpPage'
import { UrmiOtpDocsPage } from './features/agent/UrmiOtpDocsPage' import { UrmiOtpDocsPage } from './features/agent/UrmiOtpDocsPage'
import { KycPage } from './features/compliance/KycPage' import { KycPage } from './features/compliance/KycPage'
@ -53,6 +54,7 @@ export default function App() {
<Route path="message-settings" element={<MessageSettings />} /> <Route path="message-settings" element={<MessageSettings />} />
<Route path="templates" element={<TemplatesPage />} /> <Route path="templates" element={<TemplatesPage />} />
<Route path="messaging" element={<ComposeMessage />} /> <Route path="messaging" element={<ComposeMessage />} />
<Route path="whatsapp" element={<WhatsAppSetupPage />} />
<Route path="campaigns" element={<CampaignsPage />} /> <Route path="campaigns" element={<CampaignsPage />} />
<Route path="campaigns/:campaignId" element={<CampaignDetailPage />} /> <Route path="campaigns/:campaignId" element={<CampaignDetailPage />} />
<Route path="api-keys" element={<ApiKeysPage />} /> <Route path="api-keys" element={<ApiKeysPage />} />

View file

@ -4,6 +4,7 @@ import {
Code2, CreditCard, Cpu, FileCheck2, Gauge, KeyRound, LayoutDashboard, Code2, CreditCard, Cpu, FileCheck2, Gauge, KeyRound, LayoutDashboard,
LogOut, Menu, MessageSquareText, Network, Phone, Search, Settings, ShieldCheck, LogOut, Menu, MessageSquareText, Network, Phone, Search, Settings, ShieldCheck,
SlidersHorizontal, TestTube2, Users, Webhook, X, Zap, SlidersHorizontal, TestTube2, Users, Webhook, X, Zap,
MessageCircle,
} from 'lucide-react' } from 'lucide-react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom' import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { useAuth } from '../features/auth/auth-context' import { useAuth } from '../features/auth/auth-context'
@ -28,6 +29,7 @@ const groups = [
]}, ]},
{ label: 'Communicate', links: [ { label: 'Communicate', links: [
{ label: 'Messaging', path: '/console/messaging', icon: MessageSquareText }, { label: 'Messaging', path: '/console/messaging', icon: MessageSquareText },
{ label: 'WhatsApp', path: '/console/whatsapp', icon: MessageCircle },
{ label: 'Campaigns', path: '/console/campaigns', icon: Zap }, { label: 'Campaigns', path: '/console/campaigns', icon: Zap },
{ label: 'Templates', path: '/console/templates', icon: BookOpen }, { label: 'Templates', path: '/console/templates', icon: BookOpen },
]}, ]},

View file

@ -0,0 +1,139 @@
import { Check, Copy, ExternalLink, MessageCircle } from 'lucide-react'
import { useEffect, useState } from 'react'
import { whatsappApi, type WhatsAppSetup, type WhatsAppWebhookEvent } from '../../lib/api'
function ConfigBadge({ ok, label }: { ok: boolean; label: string }) {
return (
<span className={`whatsapp-config-badge ${ok ? 'ok' : 'missing'}`}>
<i /> {label}: {ok ? 'Configured' : 'Missing'}
</span>
)
}
export function WhatsAppSetupPage() {
const [setup, setSetup] = useState<WhatsAppSetup | null>(null)
const [events, setEvents] = useState<WhatsAppWebhookEvent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [copied, setCopied] = useState<string | null>(null)
useEffect(() => {
Promise.all([whatsappApi.setup(), whatsappApi.events()])
.then(([setupPayload, eventPayload]) => {
setSetup(setupPayload)
setEvents(eventPayload)
})
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load WhatsApp setup'))
.finally(() => setLoading(false))
}, [])
async function copyValue(key: string, value: string) {
await navigator.clipboard.writeText(value)
setCopied(key)
window.setTimeout(() => setCopied(null), 2000)
}
if (loading) return <p className="explorer-loading">Loading WhatsApp setup…</p>
return (
<div className="whatsapp-setup-page">
<div className="account-heading">
<span className="eyebrow">Communicate</span>
<h1>WhatsApp</h1>
<p>Configure Meta webhooks so Urmi can receive messages and delivery updates on behalf of your customers.</p>
</div>
{error && <p className="auth-error">{error}</p>}
{setup && (
<>
<section className="panel whatsapp-panel">
<div className="panel-heading">
<div>
<h2><MessageCircle size={18} /> Meta webhook configuration</h2>
<p>Paste these into <strong>App Dashboard → WhatsApp → Configuration → Configure Webhooks</strong></p>
</div>
<a className="ghost-button" href={setup.docs_url} target="_blank" rel="noreferrer">
Meta docs <ExternalLink size={14} />
</a>
</div>
<div className="whatsapp-config-row">
<div>
<span>Callback URL</span>
<code>{setup.webhook_url}</code>
</div>
<button className="secondary-button" onClick={() => copyValue('url', setup.webhook_url)}>
<Copy size={14} /> {copied === 'url' ? 'Copied' : 'Copy'}
</button>
</div>
<div className="whatsapp-config-row">
<div>
<span>Verify token</span>
<code>{setup.verify_token_configured ? 'Set in server env (WHATSAPP_VERIFY_TOKEN)' : 'Not configured'}</code>
<small>Use the exact same string in Meta and in your <code>.env</code> file.</small>
</div>
</div>
<div className="whatsapp-badges">
<ConfigBadge ok={setup.verify_token_configured} label="Verify token" />
<ConfigBadge ok={setup.app_secret_configured} label="App secret" />
<ConfigBadge ok={setup.access_token_configured} label="Access token" />
<ConfigBadge ok={Boolean(setup.phone_number_id)} label="Phone number ID" />
<ConfigBadge ok={Boolean(setup.business_account_id)} label="WABA ID" />
</div>
</section>
<section className="panel whatsapp-panel">
<div className="panel-heading">
<div>
<h2>Subscribe to webhook fields</h2>
<p>In Meta, subscribe to these fields after verifying the callback URL.</p>
</div>
</div>
<ul className="whatsapp-field-list">
{setup.recommended_fields.map((field) => (
<li key={field}><Check size={14} /> <code>{field}</code></li>
))}
</ul>
<p className="relay-note">
Minimum for messaging: <code>messages</code>. For a tech provider / BSP, also enable template and account fields.
</p>
</section>
<section className="panel whatsapp-panel">
<div className="panel-heading">
<div>
<h2>Recent webhook events</h2>
<p>Incoming payloads from Meta (after verification succeeds).</p>
</div>
</div>
{events.length === 0 ? (
<p className="analytics-empty">No webhook events yet. Click <strong>Verify and save</strong> in Meta to send a test.</p>
) : (
<div className="logs-table">
<div className="logs-table-head logs-cols-5">
<span>Field</span>
<span>Type</span>
<span>From</span>
<span>Status</span>
<span>Time</span>
</div>
{events.map((event) => (
<div className="logs-table-row logs-cols-5" key={event.id}>
<span>{event.field}</span>
<span>{event.event_type}</span>
<span>{event.from_wa_id || '—'}</span>
<span>{event.status || '—'}</span>
<span>{new Date(event.created_at).toLocaleString()}</span>
</div>
))}
</div>
)}
</section>
</>
)}
</div>
)
}

View file

@ -590,6 +590,23 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
@media (max-width: 980px) { @media (max-width: 980px) {
.analytics-toolbar-row, .analytics-grid, .analytics-summary-grid { grid-template-columns: 1fr; } .analytics-toolbar-row, .analytics-grid, .analytics-summary-grid { grid-template-columns: 1fr; }
} }
.whatsapp-setup-page { max-width: 900px; }
.whatsapp-panel { margin-bottom: 16px; }
.whatsapp-config-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 14px 0; border-bottom: 1px solid #edf0f4; }
.whatsapp-config-row span { display: block; color: #8a95a7; font-size: 10px; font-weight: 700; text-transform: uppercase; margin-bottom: 6px; }
.whatsapp-config-row code { display: block; color: #35435a; font-size: 12px; word-break: break-all; }
.whatsapp-config-row small { display: block; margin-top: 6px; color: #8a95a7; font-size: 11px; }
.whatsapp-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.whatsapp-config-badge { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; }
.whatsapp-config-badge i { width: 6px; height: 6px; border-radius: 50%; }
.whatsapp-config-badge.ok { color: #0f7a49; background: #e8f8ef; }
.whatsapp-config-badge.ok i { background: #0f7a49; }
.whatsapp-config-badge.missing { color: #8a5b00; background: #fff7df; }
.whatsapp-config-badge.missing i { background: #d78219; }
.whatsapp-field-list { margin: 0; padding: 0; list-style: none; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 16px; }
.whatsapp-field-list li { display: flex; align-items: center; gap: 8px; color: #35435a; font-size: 12px; }
.whatsapp-field-list svg { color: #159768; }
@media (max-width: 860px) { @media (max-width: 860px) {
.logs-toolbar-row { grid-template-columns: 1fr; } .logs-toolbar-row { grid-template-columns: 1fr; }
.logs-table-head { display: none; } .logs-table-head { display: none; }

View file

@ -788,3 +788,38 @@ export const analyticsApi = {
return jsonRequest<UsageAnalytics>(`/api/v1/analytics/usage?${query.toString()}`) return jsonRequest<UsageAnalytics>(`/api/v1/analytics/usage?${query.toString()}`)
}, },
} }
export type WhatsAppSetup = {
webhook_url: string
verify_token_configured: boolean
app_secret_configured: boolean
access_token_configured: boolean
phone_number_id: string | null
business_account_id: string | null
meta_app_id: string | null
api_version: string
recommended_fields: string[]
docs_url: string
}
export type WhatsAppWebhookEvent = {
id: string
waba_id: string | null
phone_number_id: string | null
field: string
event_type: string
from_wa_id: string | null
message_id: string | null
status: string | null
created_at: string
}
export const whatsappApi = {
setup: () => jsonRequest<WhatsAppSetup>('/api/v1/whatsapp/setup'),
events: () => jsonRequest<WhatsAppWebhookEvent[]>('/api/v1/whatsapp/events'),
sendText: (payload: { to: string; body: string }) =>
jsonRequest<Record<string, unknown>>('/api/v1/whatsapp/messages/send', {
method: 'POST',
body: JSON.stringify(payload),
}),
}