From af4cc147e90686c0e977b2c749cf2b828840b03d Mon Sep 17 00:00:00 2001 From: Jakob Holmgren Date: Tue, 20 Oct 2026 18:08:36 +0200 Subject: [PATCH 1/2] feat(Support): Add an endpoint that issues HubSpot chat visitor tokens The HubSpot chat widget shows every logged-in user as "Unknown Visitor", so tickets created from chat have no contact or company. HubSpot's Visitor Identification API fixes that, but the token must be created server-side. GET /api/v1/auth/users/me/hubspot-chat-token/ returns a token for the requesting user. It returns 404 when HUBSPOT_ACCESS_TOKEN is not set and 502 when HubSpot fails, and it is throttled per user. Co-Authored-By: Claude Opus 5.5 --- api/app/settings/common.py | 1 + api/app/settings/test.py | 1 + api/custom_auth/views.py | 39 ++++++- .../lead_tracking/hubspot/client.py | 31 ++++++ .../lead_tracking/hubspot/constants.py | 1 + .../test_unit_custom_auth_views.py | 103 ++++++++++++++++++ .../hubspot/test_unit_hubspot_client.py | 70 ++++++++++++ 7 files changed, 244 insertions(+), 2 deletions(-) diff --git a/api/app/settings/common.py b/api/app/settings/common.py index d27787a168aa..f3383db1983e 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -383,6 +383,7 @@ "invite": "10/min", "user": USER_THROTTLE_RATE, "influx_query": "5/min", + "hubspot_chat_token": "10/min", "usage_data": "30/min", "warehouse_connection_write": "10/min", "warehouse_connection_read": "60/min", diff --git a/api/app/settings/test.py b/api/app/settings/test.py index a980ea6c9163..c04cfffb3be7 100644 --- a/api/app/settings/test.py +++ b/api/app/settings/test.py @@ -47,6 +47,7 @@ "user": "100000/day", "master_api_key": "100000/day", "influx_query": "50/min", + "hubspot_chat_token": "50/min", "usage_data": "50/min", "warehouse_connection_write": "1000/min", "warehouse_connection_read": "1000/min", diff --git a/api/custom_auth/views.py b/api/custom_auth/views.py index 7352ab32d1e0..32e6835a0b59 100644 --- a/api/custom_auth/views.py +++ b/api/custom_auth/views.py @@ -7,8 +7,8 @@ from django.utils import timezone from django.utils.decorators import method_decorator from djoser.views import TokenCreateView, UserViewSet # type: ignore[import-untyped] -from drf_spectacular.utils import extend_schema -from rest_framework import status +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers, status from rest_framework.authtoken.models import Token from rest_framework.decorators import action, api_view, permission_classes from rest_framework.permissions import IsAuthenticated @@ -31,6 +31,7 @@ from custom_auth.mfa.trench.serializers import CodeLoginSerializer from custom_auth.mfa.trench.utils import user_token_generator from custom_auth.serializers import CustomUserDelete +from integrations.lead_tracking.hubspot.client import HubspotClient from integrations.lead_tracking.hubspot.services import ( register_hubspot_tracker_and_track_user, ) @@ -117,6 +118,7 @@ class FFAdminUserViewSet(UserViewSet): # type: ignore[misc] throttle_scopes_by_action = { "create": "signup", "resend_activation": "resend_activation", + "hubspot_chat_token": "hubspot_chat_token", } def perform_authentication(self, request: Request) -> None: @@ -183,6 +185,39 @@ def patch_onboarding(self, request: Request, *args: Any, **kwargs: Any) -> Respo return Response(status=status.HTTP_204_NO_CONTENT) + @extend_schema( + responses={ + 200: inline_serializer( + name="HubspotChatTokenResponse", + fields={"token": serializers.CharField()}, + ), + 404: None, + 502: None, + }, + ) + @action( + detail=False, + methods=["get"], + url_path="me/hubspot-chat-token", + permission_classes=[IsAuthenticated], + ) + def hubspot_chat_token( + self, request: Request, *args: Any, **kwargs: Any + ) -> Response: + """ + Return a token that identifies the user to the HubSpot chat widget. + """ + if not settings.HUBSPOT_ACCESS_TOKEN: + return Response(status=status.HTTP_404_NOT_FOUND) + + user = request.user + assert isinstance(user, FFAdminUser) + token = HubspotClient().create_visitor_identification_token(user) + if token is None: + return Response(status=status.HTTP_502_BAD_GATEWAY) + + return Response({"token": token}) + @action(["post"], detail=False) def reset_password(self, request: Request, *args: Any, **kwargs: Any) -> Response: serializer = self.get_serializer(data=request.data) diff --git a/api/integrations/lead_tracking/hubspot/client.py b/api/integrations/lead_tracking/hubspot/client.py index f02f2892977a..70827189d025 100644 --- a/api/integrations/lead_tracking/hubspot/client.py +++ b/api/integrations/lead_tracking/hubspot/client.py @@ -4,7 +4,12 @@ import hubspot # type: ignore[import-untyped] import requests +import urllib3 from django.conf import settings +from hubspot.conversations.visitor_identification import ( # type: ignore[import-untyped] + ApiException, + IdentificationTokenGenerationRequest, +) from hubspot.crm.companies import ( # type: ignore[import-untyped] PublicObjectSearchRequest, SimplePublicObjectInput, @@ -18,6 +23,7 @@ HUBSPOT_FORM_ID_SAAS, HUBSPOT_PORTAL_ID, HUBSPOT_ROOT_FORM_URL, + HUBSPOT_VISITOR_IDENTIFICATION_TIMEOUT_SECONDS, ) if TYPE_CHECKING: @@ -54,6 +60,31 @@ def get_contact(self, user: "FFAdminUser") -> None | dict[str, Any]: return results[0] # type: ignore[no-any-return] + def create_visitor_identification_token(self, user: "FFAdminUser") -> str | None: + """ + Create a token that identifies the user to the HubSpot chat widget. + HubSpot tokens are valid for 12 hours. Returns None if HubSpot fails. + """ + request = IdentificationTokenGenerationRequest( + email=user.email, + first_name=user.first_name or None, + last_name=user.last_name or None, + ) + generate_api = self.client.conversations.visitor_identification.generate_api + try: + response = generate_api.generate_token( + identification_token_generation_request=request, + _request_timeout=HUBSPOT_VISITOR_IDENTIFICATION_TIMEOUT_SECONDS, + ) + except (ApiException, urllib3.exceptions.HTTPError): + logger.exception( + "Failed to create HubSpot visitor identification token for user %d", + user.id, + ) + return None + + return response.token # type: ignore[no-any-return] + def create_lead_form( self, user: "FFAdminUser", diff --git a/api/integrations/lead_tracking/hubspot/constants.py b/api/integrations/lead_tracking/hubspot/constants.py index a9fd94ddc789..99e177b9a0c1 100644 --- a/api/integrations/lead_tracking/hubspot/constants.py +++ b/api/integrations/lead_tracking/hubspot/constants.py @@ -3,6 +3,7 @@ HUBSPOT_FORM_ID_SAAS = "562ee023-fb3f-4645-a217-4d8c9b4e45be" HUBSPOT_FORM_ID_SELF_HOSTED = "e79b8eca-a727-4188-a5ed-8f71c707ac50" HUBSPOT_ROOT_FORM_URL = "https://api.hsforms.com/submissions/v3/integration/submit" +HUBSPOT_VISITOR_IDENTIFICATION_TIMEOUT_SECONDS = 5 HUBSPOT_API_LEAD_SOURCE_SELF_HOSTED = "self-hosted" HUBSPOT_ACTIVE_SUBSCRIPTION_SELF_HOSTED = "free-opensource" diff --git a/api/tests/unit/custom_auth/test_unit_custom_auth_views.py b/api/tests/unit/custom_auth/test_unit_custom_auth_views.py index f5de5aafd0cc..70b7b76827ca 100644 --- a/api/tests/unit/custom_auth/test_unit_custom_auth_views.py +++ b/api/tests/unit/custom_auth/test_unit_custom_auth_views.py @@ -6,8 +6,10 @@ from django.urls import reverse from freezegun import freeze_time from pytest_django.fixtures import SettingsWrapper +from pytest_mock import MockerFixture from rest_framework import status from rest_framework.test import APIClient +from rest_framework.throttling import ScopedRateThrottle from users.models import FFAdminUser, HubspotTracker @@ -335,3 +337,104 @@ def test_create_user__invalid_signup_anonymous_id__returns_400( assert response.status_code == status.HTTP_400_BAD_REQUEST assert "signup_anonymous_id" in response.json() assert not FFAdminUser.objects.filter(email="test@exemple.fr").exists() + + +def test_hubspot_chat_token__hubspot_configured__returns_token( + staff_user: FFAdminUser, + staff_client: APIClient, + settings: SettingsWrapper, + mocker: MockerFixture, +) -> None: + # Given + settings.HUBSPOT_ACCESS_TOKEN = "test-access-token" + mock_hubspot_client = mocker.patch("custom_auth.views.HubspotClient") + mock_create_token = ( + mock_hubspot_client.return_value.create_visitor_identification_token + ) + mock_create_token.return_value = "test-token" + url = reverse("api-v1:custom_auth:ffadminuser-hubspot-chat-token") + + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"token": "test-token"} + mock_create_token.assert_called_once_with(staff_user) + + +def test_hubspot_chat_token__hubspot_not_configured__returns_404( + staff_client: APIClient, + settings: SettingsWrapper, + mocker: MockerFixture, +) -> None: + # Given + settings.HUBSPOT_ACCESS_TOKEN = None + mock_hubspot_client = mocker.patch("custom_auth.views.HubspotClient") + url = reverse("api-v1:custom_auth:ffadminuser-hubspot-chat-token") + + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_404_NOT_FOUND + mock_hubspot_client.assert_not_called() + + +def test_hubspot_chat_token__hubspot_fails__returns_502( + staff_client: APIClient, + settings: SettingsWrapper, + mocker: MockerFixture, +) -> None: + # Given + settings.HUBSPOT_ACCESS_TOKEN = "test-access-token" + mock_hubspot_client = mocker.patch("custom_auth.views.HubspotClient") + mock_hubspot_client.return_value.create_visitor_identification_token.return_value = None + url = reverse("api-v1:custom_auth:ffadminuser-hubspot-chat-token") + + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_502_BAD_GATEWAY + + +def test_hubspot_chat_token__unauthenticated__returns_401( + api_client: APIClient, + settings: SettingsWrapper, + mocker: MockerFixture, +) -> None: + # Given + settings.HUBSPOT_ACCESS_TOKEN = "test-access-token" + mock_hubspot_client = mocker.patch("custom_auth.views.HubspotClient") + url = reverse("api-v1:custom_auth:ffadminuser-hubspot-chat-token") + + # When + response = api_client.get(url) + + # Then + assert response.status_code == status.HTTP_401_UNAUTHORIZED + mock_hubspot_client.assert_not_called() + + +def test_hubspot_chat_token__rate_exceeded__returns_429( + staff_client: APIClient, + reset_cache: None, + settings: SettingsWrapper, + mocker: MockerFixture, +) -> None: + # Given + settings.HUBSPOT_ACCESS_TOKEN = "test-access-token" + mocker.patch.dict( + ScopedRateThrottle.THROTTLE_RATES, {"hubspot_chat_token": "1/min"} + ) + mock_hubspot_client = mocker.patch("custom_auth.views.HubspotClient") + mock_hubspot_client.return_value.create_visitor_identification_token.return_value = "test-token" + url = reverse("api-v1:custom_auth:ffadminuser-hubspot-chat-token") + staff_client.get(url) + + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS diff --git a/api/tests/unit/integrations/lead_tracking/hubspot/test_unit_hubspot_client.py b/api/tests/unit/integrations/lead_tracking/hubspot/test_unit_hubspot_client.py index 07b839f78dd1..74f6578cbc46 100644 --- a/api/tests/unit/integrations/lead_tracking/hubspot/test_unit_hubspot_client.py +++ b/api/tests/unit/integrations/lead_tracking/hubspot/test_unit_hubspot_client.py @@ -3,6 +3,10 @@ import pytest import responses +import urllib3 +from hubspot.conversations.visitor_identification import ( # type: ignore[import-untyped] + ApiException, +) from pytest_mock import MockerFixture from rest_framework import status @@ -11,6 +15,7 @@ HUBSPOT_FORM_ID_SAAS, HUBSPOT_PORTAL_ID, HUBSPOT_ROOT_FORM_URL, + HUBSPOT_VISITOR_IDENTIFICATION_TIMEOUT_SECONDS, ) from tests.unit.integrations.lead_tracking.hubspot._hubspot_responses import ( generate_create_company_response, @@ -265,3 +270,68 @@ def test_update_company__with_properties__calls_hubspot_api_with_expected_proper simple_input = call_kwargs["simple_public_object_input"] assert simple_input.properties == expected_properties + + +@pytest.mark.parametrize( + "first_name, last_name, expected_first_name, expected_last_name", + [ + ("Ada", "Lovelace", "Ada", "Lovelace"), + ("", "", None, None), + ], +) +def test_create_visitor_identification_token__hubspot_returns_token__returns_token( + db: None, + hubspot_client: HubspotClient, + first_name: str, + last_name: str, + expected_first_name: str | None, + expected_last_name: str | None, +) -> None: + # Given + user = FFAdminUser.objects.create( + email="chat@example.com", first_name=first_name, last_name=last_name + ) + mock_generate_token = hubspot_client.client.conversations.visitor_identification.generate_api.generate_token + mock_generate_token.return_value.token = "test-token" + + # When + token = hubspot_client.create_visitor_identification_token(user) + + # Then + assert token == "test-token" + mock_generate_token.assert_called_once() + _, call_kwargs = mock_generate_token.call_args + request = call_kwargs["identification_token_generation_request"] + assert request.email == "chat@example.com" + assert request.first_name == expected_first_name + assert request.last_name == expected_last_name + assert ( + call_kwargs["_request_timeout"] + == HUBSPOT_VISITOR_IDENTIFICATION_TIMEOUT_SECONDS + ) + + +@pytest.mark.parametrize( + "error", + [ + ApiException(status=403, reason="Forbidden"), + urllib3.exceptions.ReadTimeoutError(None, "", "Read timed out."), # type: ignore[arg-type] + ], +) +def test_create_visitor_identification_token__hubspot_fails__logs_and_returns_none( + staff_user: FFAdminUser, + hubspot_client: HubspotClient, + caplog: pytest.LogCaptureFixture, + error: Exception, +) -> None: + # Given + hubspot_client.client.conversations.visitor_identification.generate_api.generate_token.side_effect = error + + # When + token = hubspot_client.create_visitor_identification_token(staff_user) + + # Then + assert token is None + assert caplog.messages == [ + f"Failed to create HubSpot visitor identification token for user {staff_user.id}" + ] From e8b55c539ad31ba621d1067e1ed147e5bf7a039a Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Tue, 20 Oct 2026 16:14:45 +0000 Subject: [PATCH 2/2] chore: Update documentation artefacts --- openapi.yaml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/openapi.yaml b/openapi.yaml index 05c5eb17d1fa..2d448eae73ad 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1250,6 +1250,26 @@ paths: - Master API Key: [] tags: - Authentication + /api/v1/auth/users/me/hubspot-chat-token/: + get: + operationId: api_v1_auth_users_me_hubspot_chat_token_retrieve + description: Return a token that identifies the user to the HubSpot chat widget. + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/HubspotChatTokenResponse' + '404': + description: No response body + '502': + description: No response body + security: + - tokenAuth: [] + - Master API Key: [] + tags: + - Authentication /api/v1/auth/users/me/onboarding/: patch: operationId: api_v1_auth_users_me_onboarding_partial_update @@ -22983,6 +23003,13 @@ components: maxLength: 200 required: - api_key + HubspotChatTokenResponse: + type: object + properties: + token: + type: string + required: + - token IdentifierOnlyIdentity: type: object properties: