D1A - Vessel Vanguard → HubSpot
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:
- Go to: HubSpot → Settings → Integrations → Private Apps
- Click Create private app
- Give it a name: Vessel Vanguard Integration
- 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)
- 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
- OrganizationData
- UserData
HubSpot
- Company
- Contact
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
- vv_user_id
- vv_cognito_id
- vv_login_count
- vv_last_login_date
- vv_recency_score
- vv_role
- vv_org_id
Type
- text
- text
- number
- datetime
- number
- text / dropdown
- text
Description
- Primary external UUID for matching
- Cognito user sub
- Total login count
- Timestamp of last login
- Derived days since last login
- User role inside VV
- Organization foreign key
Source (VV)
- UserData.id
- UserData.cognitoId
- UserData.loginCount
- UserData.lastLoginAt
- Computed
- UserData.role
- UserData.organizationId
Optional future:
vv_role
vv_permission_level
vv_account_owner
For Companies (Users) we will need:
Property name
- vv_org_id
- vv_org_name
- vv_org_status
- vv_org_created_at
- vv_org_vessel_count
- vv_last_sync
Type
- text
- text
- dropdown
- datetime
- number
- datetime
Description
- Organization UUID (primary key)
- VV organization name
- Active / Suspended / Closed
- Organization creation date
- VV vessel count
- Timestamp of the last HS sync
Source (VV)
- OrganizationData.id
- OrganizationData.name
- OrganizationData.status
- OrganizationData.createdAt
- OrganizationData.vesselCount
- Integration
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)
- Match by vv_org_id
- Fallback: match by organisation name
- If no match → create new Company record
Mapped Fields (Technical Table)
Users (Contact Mapping)
HubSpot Property
- Firstname
- lastname
- phone
- vv_user-id
- vv_cognito_id
- vv_login_count
- vv_last_login_date
- vv_recency_score
- vv_role
- companyId
VV Field
- firstname
- lastname
- emailAddress
- phoneNumber
- id
- cognitoId
- loginCount
- lastLoginAt
- DaysBetween(NOW, lastLoginAt)
- role
- organizationId
Notes
- -
- -
- -
- -
- Primary matching field
- Strong fallback
- Synced daily
- -
- Computed
- -
- Links to Company
Organisations (Contact Mapping)
HubSpot Property
- name
- domain
- vv_org_id
- vv_org_status
- vv_org_created_at
- vv_org_vessel_count
VV Field
- name
- Computed or blank
- id
- status
- createdAt
- vesselIds.length
Notes
- -
- Optional
- Strong match
- -
- -
- -
Technical Implementation
Trigger: Vessel Vanguard system logs every user login.
Process:
- VV system logs new login event
- → increments login count
- → updates last login timestamp
- A scheduled integration job (cron / AWS Lambda / backend task):
- Fetches most recent login activity for all VV users
- Maps VV user → HubSpot contact
- 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:
- Detect login success with route === “authenticated”.
- Get the logged-in user (including Cognito attributes) using useAuthenticator().
- Extract the Cognito sub ID.
- Call your AppSync GraphQL mutation:
• Using AWS_IAM signed request (Signature v4) – because our Lambdas and secure backend paths
use IAM, not API key. - 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:
- 1. Amplify Auth logs them in with Cognito
- 2. route === "authenticated" fires.
- 3. We fetch the VV user using the Cognito ID (sub).
-
4. We increment:
• loginCount
• lastLoginAt - 5. Save to DynamoDB via AppSync (IAM secured).
- 6. Navigate user to their destination.
STEP 3 — Data Sync to HubSpot
1. Fetch all VV Users using GraphQL:
listUserData
2. For each user:
- Search HubSpot for matching contact
- If exists → update properties
- 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