PLAN: Webhooks booking-side — receber eventos dos SaaS
Contexto
Estratégia P2 aprovada: platform é hub billing, SaaS precisam de notificar o platform quando eventos ocorrem (tenant status change, usage reports, provisioning failures). Actualmente o platform só tem 1 callback endpoint para provisioning success — sem cobertura para falhas, status changes ou usage.
Regra arquitectural: platform é publisher NATS only (nunca subscriber). Eventos dos SaaS chegam via HTTP webhooks autenticados.
Objectivo
Criar endpoint genérico POST /api/webhooks/saas-events que recebe eventos de qualquer SaaS (booking, virtual-stores) e despacha para handlers por event_type. Autenticação via HMAC shared secret (por produto).
Ficheiros afectados — 4 ficheiros
| # | Ficheiro | Mudança |
|---|---|---|
| 1 | backend/src/modules/provisioning/schemas.py | CRIAR — schemas Pydantic para SaaS events |
| 2 | backend/src/modules/provisioning/saas_webhook_handler.py | CRIAR — handler class com dispatch por event_type |
| 3 | backend/src/modules/provisioning/routes.py | EDITAR — adicionar POST /webhooks/saas-events |
| 4 | backend/src/core/config.py | EDITAR — adicionar saas_webhook_secrets (dict por produto) |
1. Schemas — provisioning/schemas.py (CRIAR)
from datetime import datetime
from pydantic import BaseModel, Field
class SaaSEventPayload(BaseModel):
event_type: str = Field(..., description="tenant.status_changed | usage.report | provisioning.confirmed | provisioning.failed")
source: str = Field(..., description="booking-system | virtual-stores-system")
tenant_id: int | None = None
service_id: int | None = None
timestamp: datetime
data: dict = Field(default_factory=dict)
class TenantStatusChangedData(BaseModel):
new_status: str # active | suspended | terminated
reason: str | None = None
platform_service_id: int | None = None
class UsageReportData(BaseModel):
metric: str # bookings_count | active_users | storage_mb
value: float
period_start: datetime
period_end: datetime
subscription_id: int | None = None
class ProvisioningResultData(BaseModel):
external_id: str | None = None
external_url: str | None = None
error_message: str | None = None
2. Handler — provisioning/saas_webhook_handler.py (CRIAR)
class SaaSWebhookHandler:
def __init__(self, db: Session):
self.db = db
async def dispatch(self, event: SaaSEventPayload):
handlers = {
"tenant.status_changed": self._handle_status_changed,
"usage.report": self._handle_usage_report,
"provisioning.confirmed": self._handle_provisioning_confirmed,
"provisioning.failed": self._handle_provisioning_failed,
}
handler = handlers.get(event.event_type)
if not handler:
logger.warning(f"Unknown SaaS event type: {event.event_type}")
return {"status": "ignored", "reason": "unknown_event_type"}
return await handler(event)
Handlers:
_handle_status_changed: queryClientServicebyservice_idortenant_id+source, update status, log + notify sysadmin_handle_usage_report: criarUsageRecordna BD para billing metered (usa modelo existente da migração Alembic)_handle_provisioning_confirmed: reutilizar lógica do callback existente (set external_id, status=active)_handle_provisioning_failed: marcar service comofailed, ProvisioningRecord comofailed, notificar sysadmin
3. Route — provisioning/routes.py (EDITAR)
Adicionar endpoint:
@router.post("/webhooks/saas-events")
async def saas_events_webhook(request: Request, db: Session = Depends(get_db)):
body = await request.body()
signature = request.headers.get("X-Webhook-Signature", "")
source = request.headers.get("X-Webhook-Source", "")
secret = settings.saas_webhook_secrets.get(source, "")
if not secret or not verify_hmac(body, signature, secret):
raise HTTPException(status_code=403, detail="Invalid signature")
event = SaaSEventPayload.model_validate_json(body)
handler = SaaSWebhookHandler(db)
result = await handler.dispatch(event)
return result
Helper verify_hmac:
import hashlib, hmac
def verify_hmac(body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
4. Config — core/config.py (EDITAR)
Após provisioning_callback_secret adicionar:
# SaaS webhook secrets (HMAC per product)
saas_webhook_secret_booking: str = ""
saas_webhook_secret_stores: str = ""
@property
def saas_webhook_secrets(self) -> dict[str, str]:
return {
"booking-system": self.saas_webhook_secret_booking,
"virtual-stores-system": self.saas_webhook_secret_stores,
}
Validação
python -m py_compilenos 4 ficheiroscd backend && python -m pytest src/tests/test_provisioning.py -vruff checknos ficheiros novos- Pipeline green
Fora de scope
- SDK schemas para SaaS→platform (SDK é read-only, requer coordenação VPE)
- NATS subscriber no platform (regra: publisher only)
- Implementação do lado booking/stores (responsabilidade dos outros Tech Leads)
- Testes E2E (endpoint interno, não há UI)