D1A - Vessel Vanguard → HubSpot

Following is the outline of what we need to deploy VesselVanguard → HubSpot usage analytics.

Solution Overview

To start development, we need to setup two things in HubSpot:

1. A Private App

HubSpot no longer uses API keys. Instead please create a Private App to manage the integration. Steps:

  1. Go to: HubSpot → Settings → Integrations → Private Apps
  2. Click Create private app
  3. Give it a name: Vessel Vanguard Integration
  4. Under Scopes, enable:
    • crm.objects.contacts.read

    • crm.objects.contacts.write

    • crm.objects.companies.read

    • crm.objects.companies.write

    • crm.schemas.companies.read

    • crm.schemas.contacts.read

    • Custom objects (if needed later)
  5. Save & generate the Access Token
    → This is our HubSpot Bearer token.

We’ll need this for API calls:

Authorization: Bearer

Content-Type: application/json2. HubSpot Objects for Sync

Because we’re mapping Vessel Vanguard Organizations + Users to HubSpot Companies + Contacts, here’s
the object alignment (note Vessel Vanguard (VV) → HubSpot (HS) ONLY):

Vessel Vanguard
HubSpot

Sync model:

  • VV is the source of truth for login activity, user-role, and organization membership.
  • HubSpot stores CRM-facing properties for:
    • User engagement
    • Lifecycle automation
    • Contact scoring
    • Customer success workflows

Sync Frequency:

  • Daily scheduled import (Lambda / cron)

2. Required Custom Properties

For Contacts (Users) we will need:

Property name
Type
Description
Source (VV)

Optional future:

  • vv_role

  • vv_permission_level

  • vv_account_owner


For Companies (Users) we will need:

Property name
Type
Description
Source (VV)

Optional future:

  • Organisation billing tier
  • Subscription renewal date
  • Assigned CSM / AM

You create these under: HubSpot → Settings → Data Management → Properties

Matching & Identity Resolution Logic

User Matching Rules (HubSpot Contact)

Match users in this order:

Rule 1 — Match by vv_user_id (strong match)

propertyName: “vv_user_id”, operator: EQ, value: [UserData.id]

Rule 2— Match by email (fallback match)

PropertyName: “email”, operator: EQ, value: [UserData.emailAdress]

Rule 3 — Match by Cognito ID (vv_cognito_id)

propertyName: “vv_cognito_id”, operator: EQ, value: [UserData.cognitoId]

If no contact found → CREATE a new HubSpot Contact

Organisation Matching Rules (HubSpot Company)

  1. Match by vv_org_id
  2. Fallback: match by organisation name
  3. If no match → create new Company record

Mapped Fields (Technical Table)

Users (Contact Mapping)

HubSpot Property
VV Field
Notes

Organisations (Contact Mapping)

HubSpot Property
VV Field
Notes

Technical Implementation

Trigger: Vessel Vanguard system logs every user login.

Process:

  1. VV system logs new login event
    1. → increments login count
    2. → updates last login timestamp
  2. A scheduled integration job (cron / AWS Lambda / backend task):
    1. Fetches most recent login activity for all VV users
    2. Maps VV user → HubSpot contact
    3. Updates contact properties:
      • vv_login_count
      • vv_last_login_date
      • vv_recency_score

3. HubSpot stores login telemetry for CRM, automation, and lifecycle scoring.

Technical Sequence Diagram (First Deliverable)

[Vessel Vanguard DB]

    └── (1) Query login activity

        └── [Integration Service]

            └── (2) For each user: search HubSpot contact by vv_user_id

                └── [HubSpot Contacts API]

                    └── (3) Update vv_login_count, vv_last_login_date, vv_recency_score

                        └── [HubSpot CRM]

NOTE: VV → HubSpot only

Process:

How to capture login telemetry inside VV, then map it to HubSpot.

Vessel Vanguard Does NOT Yet Track Login Count or Last Login

Because these fields do not exist in VV, we must add them to the system before any HubSpot sync can occur.

Here’s the complete plan:

STEP 1 — Extend the Vessel Vanguard User Model

In Amplify (GraphQL schema), the user type likely looks something like:

  type UserData @model {

    id: ID!

    username: String!

    email: String!

    cognitoId: String

    # …other fields

  }

To support login tracking, we add: 

  type UserData @model {

    id: ID!

    username: String!

    email: String!

    cognitoId: String

    loginCount: Int 

    lastLoginAt: AWSDateTime

  }

Outcome: Every user in VV will now have loginCount and lastLoginAt.

Note: Rerun amplify push so DynamoDB + GraphQL API update automatically

STEP 2 — Modify the User Login Flow

Next, add the instrumentation so each login updates those two fields. Because we are are using Amplify UI / Amplify Auth (withAuthenticator, ) which behind the scenes calls Cognito Hosted APIs, but no triggers fire, we must implement login telemetry in your front-end login success handler, not Cognito.

Amplify-generated Cognito pools do not enable triggers unless you manually configure them. Fortunately
this is not only easier — it is the recommended Amplify approach.

The correct place to hook login telemetry is inside the useEffect(() => { if (route ===
“authenticated”) { … }})This runs once immediately after a successful login.

So the plan is:

  1. Detect login success with route === “authenticated”.
  2. Get the logged-in user (including Cognito attributes) using useAuthenticator().
  3. Extract the Cognito sub ID.
  4. Call your AppSync GraphQL mutation:
    • Using AWS_IAM signed request (Signature v4) – because our Lambdas and secure backend paths
    use IAM, not API key.
  5. ncrement loginCount and update lastLoginAt.

Below is my Pseudo code for fully working solution.

ADD A GRAPHQL MUTATION TO INCREMENT LOGIN COUNT

Add this to a new file, e.g. src/graphql/mutationsCustom.ts:

  export const updateLoginStats = /* GraphQL */ `

    mutation UpdateUserData($input: UpdateUserDataInput!) {

      updateUserData(input: $input) {

        id login

        Count lastLoginAt

      }

    }

  `;

ADD A SIGNED APPSYNC CLIENT (IAM AUTH) TO THE FRONTEND

Create a helper file: src/utils/appsyncClient.ts

  import { SigV4Utils } from “@aws-amplify/core”;

  import { Auth } from “aws-amplify”;

  export async function callAppSync(query: string, variables: any) {

    const { aws_appsync_graphqlEndpoint: url, aws_appsync_region: region } =

      (window as any).awsmobile ?? {};

    const credentials = await Auth.currentCredentials();

    const signedRequest = SigV4Utils.signRequest(

      {

        method: “POST”,

        url,

        headers: {

          “Content-Type”: “application/json”,

        },

        body: JSON.stringify({

          query,

          variables,

        }),

      },

      {

        access_key: credentials.accessKeyId,

        secret_key: credentials.secretAccessKey,

        session_token: credentials.sessionToken,

        region,

        service: “appsync”,

      }

    );

    const response = await fetch(url, signedRequest);

    const json = await response.json();

    return json.data;

  }

This should work using Amplify Auth to give us AWS credentials usable for IAM-authenticated AppSync calls. I think this matches our real backend configuration (IAM for secure ops).

INSERT LOGIN TELEMETRY INTO LOGIN.TSX

Apply something like this inside the Login component.

Find this block:

  useEffect(() => {

    if (route === “authenticated”) {

      navigate(from, {

        replace: true

      })

    }

  }, [route, navigate, from])

Replace it with this:

  import { updateLoginStats } from “../graphql/mutationsCustom”;

  import { callAppSync } from “../utils/appsyncClient”;

  import { useAuthenticator } from “@aws-amplify/ui-react”;

  export function Login() {

    const { route, user } = useAuthenticator((context) =>[context.route, context.user]);

… then replace the useEffect with:

  useEffect(() => {

    async function recordLogin() {

      if (!user?.attributes?.sub) return;

      // 1. Find VV user by Cognito ID

      const vvUserResult = await callAppSync( `

        query ListUsersByCognitoId($cognitoId: String!) {

          listUsersByCognitoId(cognitoId: $cognitoId) {

            items {

              id

              loginCount

            }

          }

        }

       `,

      { cognitoId: user.attributes.sub }

    );

    const vvUser = vvUserResult?.listUsersByCognitoId?.items?.[0];

    if (!vvUser) {

    console.warn(“Could not find VV user for Cognito ID:”, user.attributes.sub);

      return navigate(from, { replace: true });

    }

    // 2. Update login telemetry

  await callAppSync(updateLoginStats, {

    input: {

        id: vvUser.id,

        loginCount: (vvUser.loginCount || 0) + 1,

        lastLoginAt: new Date().toISOString()

      }

    });

    // 3. Continue normal navigation

    navigate(from, { replace: true });

    }

    if (route === “authenticated”) {

    recordLogin();

    }

  }, [route, user, navigate, from]);

 

Goal: Once the user logs in:

STEP 3 — Data Sync to HubSpot

1. Fetch all VV Users using GraphQL:

listUserData

2. For each user:

    1. Search HubSpot for matching contact
    2. If exists → update properties
    3. If not → create contact

3. Compute recency score:

recency = today – lastLoginAt (in days)

4. Attach user to their organisation via HubSpot Association API:

POST /crm/v4/objects/contacts/{contactId}/associations/companies/{companyId}

5. Update organisation fields

HubSpot API Calls Needed

1.Find contact by external ID (vv_user_id)

GET: https://api.hubapi.com/crm/v3/objects/contacts/search

  Content-Type: application/json

  Authorization: Bearer {TOKEN}

  {

    “filterGroups”: [{

      “filters”: [{

        “propertyName”: “vv_user_id”,

        “operator”: “EQ”,

        “value”: “abc123”

      }]

    }],

    “properties”: [

      “email”,

      “vv_login_count”,

      “vv_last_login_date”

    ]

  }

2. Update Contact login properties

  PATCH  https://api.hubapi.com/crm/v3/objects/contacts/{contactId}

  Content-Type: application/json   Authorization: Bearer {TOKEN}

  {

    “properties”: {

      “vv_login_count”: 33,

      “vv_last_login_date”: “2025-02-16T10:22:00Z”,

      “vv_recency_score”: 2,

      “vv_role”: “Admin”

    }

  }

3. Create Contact

  POST https://api.hubapi.com/crm/v3/objects/contacts/ Content-Type: application/json Authorization: Bearer {TOKEN} { “properties”: { “vv_user_id”: “abc123”, “email”: “[email protected]”, “firstname”: “Luna”, “lastname”: “Sky”, “vv_role”: “Captain” } } 3. Company Association PUT https://api.hubapi.com//crm/v4/objects/contacts/{contactId}/associations/ companies/{companyId}/contact_to_company

Error Handling

Log & skip

  • Users missing email
  • Users missing VV Organization
  • HubSpot rate limit responses → back off & retry

Fail & alert:

  • HubSpot 401 (invalid token)
  • VV GraphQL errors on user/organization fetch
  • Invalid response from HubSpot API

Security & Compliance

  • Use HubSpot Private App token (expires only if revoked).
  • Store token in AWS Secrets Manager (/hubspot/private-app-token).
  • Lambda role should have:
    • Secret retrieval
    • AppSync invoke via IAM
  • No PII logged
  • No user credentials stored