Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions ee/api/vercel/test/test_vercel_webhooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import hmac
import json
import hashlib

from unittest.mock import MagicMock, patch

from django.test import override_settings

from rest_framework import status

from ee.api.vercel.test.base import VercelTestBase


class TestVercelWebhooks(VercelTestBase):
def setUp(self):
super().setUp()
self.url = "/webhooks/vercel"
self.secret = "test_webhook_secret"

def _sign_payload(self, payload: dict) -> str:
body = json.dumps(payload).encode("utf-8")
return hmac.new(
self.secret.encode("utf-8"),
body,
hashlib.sha1,
).hexdigest()

def _post_webhook(self, payload: dict, signature: str | None = None):
if signature is not None:
return self.client.post(
self.url,
data=json.dumps(payload),
content_type="application/json",
HTTP_X_VERCEL_SIGNATURE=signature,
)
return self.client.post(
self.url,
data=json.dumps(payload),
content_type="application/json",
)

@override_settings(VERCEL_CLIENT_INTEGRATION_SECRET="test_webhook_secret")
def test_invalid_signature_returns_401(self):
payload = {
"type": "marketplace.invoice.paid",
"payload": {"installationId": self.installation_id, "invoiceId": "mi_123"},
}

response = self._post_webhook(payload, signature="invalid_signature")

assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json()["error"] == "Invalid signature"

@override_settings(VERCEL_CLIENT_INTEGRATION_SECRET="test_webhook_secret")
def test_missing_signature_returns_401(self):
payload = {
"type": "marketplace.invoice.paid",
"payload": {"installationId": self.installation_id, "invoiceId": "mi_123"},
}

response = self._post_webhook(payload, signature=None)

assert response.status_code == status.HTTP_401_UNAUTHORIZED

@override_settings(VERCEL_CLIENT_INTEGRATION_SECRET="test_webhook_secret")
def test_missing_config_id_returns_400(self):
payload = {
"type": "marketplace.invoice.paid",
"payload": {"invoiceId": "mi_123"}, # Missing installationId
}
signature = self._sign_payload(payload)

response = self._post_webhook(payload, signature=signature)

assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "configurationId" in response.json()["error"] # Error message still says configurationId

@override_settings(VERCEL_CLIENT_INTEGRATION_SECRET="test_webhook_secret")
def test_unknown_config_returns_404(self):
payload = {
"type": "marketplace.invoice.paid",
"payload": {"installationId": "icfg_unknown", "invoiceId": "mi_123"},
}
signature = self._sign_payload(payload)

response = self._post_webhook(payload, signature=signature)

assert response.status_code == status.HTTP_404_NOT_FOUND
assert "Unknown configuration" in response.json()["error"]

@override_settings(VERCEL_CLIENT_INTEGRATION_SECRET="test_webhook_secret")
def test_non_billing_events_ignored(self):
# Non-invoice marketplace events should be ignored
for event_type in [
"integration.configuration-removed",
"deployment.created",
"marketplace.member.created", # Other marketplace events that aren't invoices
None,
]:
payload = {
"type": event_type,
"payload": {"installationId": self.installation_id},
}
signature = self._sign_payload(payload)

response = self._post_webhook(payload, signature=signature)

assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "ignored"

@override_settings(VERCEL_CLIENT_INTEGRATION_SECRET="test_webhook_secret")
@patch("ee.api.vercel.vercel_webhooks.BillingManager")
@patch("ee.api.vercel.vercel_webhooks.License")
def test_billing_event_forwarded_to_billing_service(self, mock_license_model, mock_billing_manager_class):
mock_license = MagicMock()
mock_license_model.objects.first.return_value = mock_license

mock_billing_manager = MagicMock()
mock_billing_manager_class.return_value = mock_billing_manager

payload = {
"type": "marketplace.invoice.paid",
"payload": {"installationId": self.installation_id, "invoiceId": "mi_123"},
}
signature = self._sign_payload(payload)

response = self._post_webhook(payload, signature=signature)

assert response.status_code == status.HTTP_200_OK
assert response.json()["status"] == "ok"

mock_billing_manager.handle_billing_provider_webhook.assert_called_once_with(
event_type="marketplace.invoice.paid",
event_data=payload["payload"],
organization=self.organization,
billing_provider="vercel",
)

@override_settings(VERCEL_CLIENT_INTEGRATION_SECRET="test_webhook_secret")
@patch("ee.api.vercel.vercel_webhooks.BillingManager")
@patch("ee.api.vercel.vercel_webhooks.License")
def test_billing_error_returns_500(self, mock_license_model, mock_billing_manager_class):
mock_license = MagicMock()
mock_license_model.objects.first.return_value = mock_license

mock_billing_manager = MagicMock()
mock_billing_manager_class.return_value = mock_billing_manager
mock_billing_manager.handle_billing_provider_webhook.side_effect = Exception("Billing service error")

payload = {
"type": "marketplace.invoice.paid",
"payload": {"installationId": self.installation_id, "invoiceId": "mi_123"},
}
signature = self._sign_payload(payload)

response = self._post_webhook(payload, signature=signature)

assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert "Processing failed" in response.json()["error"]

@override_settings(VERCEL_CLIENT_INTEGRATION_SECRET="test_webhook_secret")
@patch("ee.api.vercel.vercel_webhooks.License")
def test_no_license_returns_500(self, mock_license_model):
mock_license_model.objects.first.return_value = None

payload = {
"type": "marketplace.invoice.paid",
"payload": {"installationId": self.installation_id, "invoiceId": "mi_123"},
}
signature = self._sign_payload(payload)

response = self._post_webhook(payload, signature=signature)

assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert "Processing failed" in response.json()["error"]
117 changes: 117 additions & 0 deletions ee/api/vercel/vercel_webhooks.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're phasing ee out. You can add this to the main posthog folder.

Even better you can talk to the devex team to figure out where this kind of code should live. Ideally it should live inside the products folder but I have a hard time coming up with a "product" to file this under

Copy link
Contributor

@pawel-cebula pawel-cebula Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have much context on the transition from ee to posthog but won't it cause issues for hobby installs to move it now on its own? At least until we complete the migration and have some other way to separate the APIs that are only relevant for cloud.

Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import hmac
import hashlib
from typing import Any

from django.conf import settings

import structlog
from rest_framework import status
from rest_framework.decorators import api_view, authentication_classes, permission_classes
from rest_framework.request import Request
from rest_framework.response import Response

from posthog.exceptions_capture import capture_exception
from posthog.models.organization_integration import OrganizationIntegration

from ee.billing.billing_manager import BillingManager
from ee.models import License

logger = structlog.get_logger(__name__)

BILLING_EVENT_PREFIX = "marketplace.invoice."


def _is_valid_signature(payload: bytes, signature: str | None) -> bool:
if not signature:
return False

secret = getattr(settings, "VERCEL_CLIENT_INTEGRATION_SECRET", None)
if not secret:
logger.error("vercel_webhook_missing_secret")
capture_exception(Exception("VERCEL_CLIENT_INTEGRATION_SECRET not configured"), {})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MattBro I don't believe just capturing an exception is enough for this. I'm not monitoring exceptions closely. The best solution here is something that feeds into incident.io. Nice to have, ofc, but we'll miss this if we never implement it. Add to the polishing list - if you arleady have one

return False

expected = hmac.new(secret.encode("utf-8"), payload, hashlib.sha1).hexdigest()
return hmac.compare_digest(expected, signature)


def _extract_config_id(payload: dict[str, Any]) -> str | None:
# Ref: https://vercel.com/docs/observability/webhooks-overview/webhooks-api
return payload.get("installationId")


def _is_billing_event(event_type: str | None) -> bool:
return bool(event_type and event_type.startswith(BILLING_EVENT_PREFIX))


def _get_integration(config_id: str) -> OrganizationIntegration | None:
try:
return OrganizationIntegration.objects.select_related("organization").get(
kind=OrganizationIntegration.OrganizationIntegrationKind.VERCEL,
integration_id=config_id,
)
except OrganizationIntegration.DoesNotExist:
return None


def _forward_to_billing_service(event_type: str, payload: dict[str, Any], integration: OrganizationIntegration) -> None:
license = License.objects.first()
if not license:
raise ValueError("No license configured")

billing_manager = BillingManager(license=license)
billing_manager.handle_billing_provider_webhook(
event_type=event_type,
event_data=payload,
organization=integration.organization,
billing_provider="vercel",
)


@api_view(["POST"])
@authentication_classes([])
@permission_classes([])
def vercel_webhook(request: Request) -> Response:
"""
Handle Vercel webhooks. Routes billing events (marketplace.invoice.*) to the billing service.
Non-billing events are acknowledged but not processed.
"""
signature = request.headers.get("x-vercel-signature")
if not _is_valid_signature(request.body, signature):
logger.warning("vercel_webhook_invalid_signature")
return Response({"error": "Invalid signature"}, status=status.HTTP_401_UNAUTHORIZED)

event_type = request.data.get("type")
payload = request.data.get("payload", {})
config_id = _extract_config_id(payload)

logger.info("vercel_webhook_received", event_type=event_type, config_id=config_id)

if not config_id:
logger.error("vercel_webhook_missing_config_id", event_type=event_type)
return Response({"error": "Missing configurationId"}, status=status.HTTP_400_BAD_REQUEST)

if not _is_billing_event(event_type):
logger.info("vercel_webhook_non_billing_event", event_type=event_type)
return Response({"status": "ignored"}, status=status.HTTP_200_OK)

assert event_type is not None # Guaranteed by _is_billing_event check above

integration = _get_integration(config_id)
if not integration:
logger.error("vercel_webhook_unknown_config", config_id=config_id)
capture_exception(
OrganizationIntegration.DoesNotExist(),
{"config_id": config_id, "event_type": event_type},
)
return Response({"error": "Unknown configuration"}, status=status.HTTP_404_NOT_FOUND)

try:
_forward_to_billing_service(event_type, payload, integration)
except Exception as e:
logger.exception("vercel_webhook_billing_error", event_type=event_type)
capture_exception(e, {"config_id": config_id, "event_type": event_type})
return Response({"error": "Processing failed"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

logger.info("vercel_webhook_processed", event_type=event_type, org_id=str(integration.organization_id))
return Response({"status": "ok"}, status=status.HTTP_200_OK)
34 changes: 34 additions & 0 deletions ee/billing/billing_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -564,3 +564,37 @@ def get_spend_data(self, organization: Organization, params: dict[str, Any]) ->
handle_billing_service_error(res)

return res.json()

def handle_billing_provider_webhook(
self,
event_type: str,
event_data: dict[str, Any],
organization: Organization,
billing_provider: str,
) -> None:
"""
Forward billing provider webhook to billing service for processing.

Pure passthrough - no transformation of event data.
Raises exception on failure (causes webhook endpoint to return 500, triggering provider retry).
"""
res = requests.post(
f"{BILLING_SERVICE_URL}/api/webhooks/billing-provider",
headers=self.get_auth_headers(organization),
json={
"event_type": event_type,
"event_data": event_data,
"billing_provider": billing_provider,
},
timeout=30,
)

if not res.ok:
logger.error(
"billing_provider_webhook_error",
event_type=event_type,
billing_provider=billing_provider,
status_code=res.status_code,
response_text=res.text[:500] if res.text else "",
)
raise Exception(f"Billing service returned {res.status_code}: {res.text}")
3 changes: 2 additions & 1 deletion ee/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from ee.admin.oauth_views import admin_auth_check, admin_oauth_success
from ee.api import integration
from ee.api.vercel import vercel_sso
from ee.api.vercel import vercel_sso, vercel_webhooks
from ee.middleware import admin_oauth2_callback
from ee.support_sidebar_max.views import MaxChatViewSet

Expand Down Expand Up @@ -134,6 +134,7 @@ def extend_api_router() -> None:
path("max/chat/", csrf_exempt(MaxChatViewSet.as_view({"post": "create"})), name="max_chat"),
path("login/vercel/", vercel_sso.VercelSSOViewSet.as_view({"get": "sso_redirect"})),
path("login/vercel/continue", vercel_sso.VercelSSOViewSet.as_view({"get": "sso_continue"})),
path("webhooks/vercel", csrf_exempt(vercel_webhooks.vercel_webhook), name="vercel_webhooks"),
path("scim/v2/<uuid:domain_id>/Users", csrf_exempt(scim_views.SCIMUsersView.as_view()), name="scim_users"),
path(
"scim/v2/<uuid:domain_id>/Users/<int:user_id>",
Expand Down
Loading