-
Notifications
You must be signed in to change notification settings - Fork 2.1k
feat(vercel): Add webhook handler for marketplace.invoice.paid (Slice 2b) #42879
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
953e9b1
54e2ff9
b0f0e77
1249ea3
9081442
19b5f01
6d7fad2
cae97d8
14c738e
54a249c
6f62402
a5028bf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"] |
| 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") | ||
MattBro marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| capture_exception(Exception("VERCEL_CLIENT_INTEGRATION_SECRET not configured"), {}) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
MattBro marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| """ | ||
| 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) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We're phasing
eeout. You can add this to the mainposthogfolder.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
productsfolder but I have a hard time coming up with a "product" to file this underUh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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
eetoposthogbut 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.