This repository has been archived by the owner on Mar 10, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 58
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: implement hubspot webhook target endpoint + webhook setup (#2040)
- Create webhook target on Provider creation - Create / update webhook subscriptions on SyncConfig update --------- Co-authored-by: Lucas Marshall <lucasmarshall@users.noreply.github.com>
- Loading branch information
1 parent
8aa2e59
commit 7df24e1
Showing
10 changed files
with
278 additions
and
26 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
import { getDependencyContainer } from '@/dependency_container'; | ||
import { BadRequestError, NotFoundError } from '@supaglue/core/errors'; | ||
import * as crypto from 'crypto'; | ||
import type { Request, Response } from 'express'; | ||
import { Router } from 'express'; | ||
|
||
type HubspotChangedAssociationWebhookPayload = { | ||
eventId: string; | ||
subscriptionId: string; | ||
portalId: string; | ||
appId: string; | ||
occurredAt: number; // Epoch timestamp | ||
subscriptionType: string; | ||
attemptNumber: number; | ||
changeSource: string; | ||
associationType: string; | ||
fromObjectId: number; | ||
toObjectId: number; | ||
associationRemoved: boolean; | ||
isPrimaryAssociation: boolean; | ||
sourceId?: string; | ||
}; | ||
|
||
const { providerService } = getDependencyContainer(); | ||
|
||
export default function init(app: Router): void { | ||
const webhookRouter = Router(); | ||
|
||
webhookRouter.post('/_webhook', async (req: Request<HubspotChangedAssociationWebhookPayload>, res: Response) => { | ||
const provider = await providerService.findByHubspotAppId(req.body.appId); | ||
if (!provider) { | ||
throw new NotFoundError(`Provider not found for appId: ${req.body.appId}`); | ||
} | ||
|
||
const validated = validateHubSpotSignatureV3(req, provider.config.oauth.credentials.oauthClientSecret); | ||
if (!validated) { | ||
throw new BadRequestError('Invalid HubSpot signature'); | ||
} | ||
// TODO: Implement dirty flagging of record | ||
return res.status(200).end(); | ||
}); | ||
|
||
app.use('/hubspot', webhookRouter); | ||
} | ||
|
||
function validateHubSpotSignatureV3( | ||
req: Request<HubspotChangedAssociationWebhookPayload>, | ||
clientSecret: string | ||
): boolean { | ||
const signature = req.headers['x-hubspot-signature-v3'] as string; | ||
const requestUri = req.protocol + '://' + req.get('host') + req.originalUrl; | ||
const requestBody = JSON.stringify(req.body); | ||
const timestamp = req.headers['x-hubspot-request-timestamp'] as string; | ||
// Check if the timestamp is older than 5 minutes | ||
const timestampDiff = Date.now() - parseInt(timestamp); | ||
if (timestampDiff > 300000) { | ||
// 5 minutes in milliseconds | ||
return false; | ||
} | ||
|
||
// Decode URL-encoded characters in requestUri | ||
const decodedUri = decodeURIComponent(requestUri); | ||
|
||
// The request method is always POST | ||
const requestMethod = 'POST'; | ||
|
||
// Concatenate requestMethod, requestUri, requestBody, and timestamp | ||
const message = requestMethod + decodedUri + requestBody + timestamp; | ||
|
||
// Create HMAC SHA-256 hash | ||
const hmac = crypto.createHmac('sha256', clientSecret); | ||
hmac.update(message); | ||
const hash = hmac.digest('base64'); | ||
|
||
// Compare the hash with the signature | ||
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(signature)); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
import { Client as HubspotClient } from '@hubspot/api-client'; | ||
import type { SubscriptionCreateRequestEventTypeEnum } from '@hubspot/api-client/lib/codegen/webhooks'; | ||
import { BadRequestError } from '../errors'; | ||
|
||
const HUBSPOT_WEBHOOK_TARGET_URL = | ||
process.env.HUBSPOT_WEBHOOK_TARGET_URL ?? `${process.env.SUPAGLUE_SERVER_URL}/internal/hubspot/_webhook`; | ||
|
||
export const updateWebhookSubscriptions = async ( | ||
developerApiKey: string, | ||
hubspotAppId: number, | ||
hubspotStandardObjects: ('contact' | 'company' | 'deal')[] | ||
) => { | ||
const hubspotClient = new HubspotClient({ developerApiKey }); | ||
const subscriptions = await hubspotClient.webhooks.subscriptionsApi.getAll(hubspotAppId); | ||
|
||
await Promise.all( | ||
['company.associationChange', 'contact.associationChange', 'deal.associationChange'].map(async (eventType) => { | ||
const existingSubscription = subscriptions.results?.find((s) => s.eventType === eventType); | ||
if (!hubspotStandardObjects.includes(eventType.split('.')[0] as 'contact' | 'company' | 'deal')) { | ||
// Delete the subscription if it exists | ||
if (!existingSubscription) { | ||
return; | ||
} | ||
await hubspotClient.webhooks.subscriptionsApi.archive(parseInt(existingSubscription.id), hubspotAppId); | ||
return; | ||
} | ||
// Create subscription if it doesn't exist | ||
if (!existingSubscription) { | ||
return hubspotClient.webhooks.subscriptionsApi.create(hubspotAppId, { | ||
active: true, | ||
eventType: eventType as SubscriptionCreateRequestEventTypeEnum, | ||
}); | ||
} | ||
}) | ||
); | ||
}; | ||
|
||
export const deleteWebhookSubscriptions = async (developerApiKey: string, hubspotAppId: number) => { | ||
const hubspotClient = new HubspotClient({ developerApiKey }); | ||
const subscriptions = await hubspotClient.webhooks.subscriptionsApi.getAll(hubspotAppId); | ||
await Promise.all( | ||
(subscriptions.results ?? []).map((subscription) => | ||
hubspotClient.webhooks.subscriptionsApi.archive(parseInt(subscription.id), hubspotAppId) | ||
) | ||
); | ||
}; | ||
|
||
const checkWebhookTargetExists = async (hubspotClient: HubspotClient, hubspotAppId: number): Promise<boolean> => { | ||
try { | ||
const res = await hubspotClient.webhooks.settingsApi.getAll(hubspotAppId); | ||
if (res.targetUrl === HUBSPOT_WEBHOOK_TARGET_URL) { | ||
return true; | ||
} | ||
} catch (e: any) { | ||
if (e.code === 404) { | ||
return false; | ||
} | ||
throw e; | ||
} | ||
throw new BadRequestError( | ||
`Your Hubspot Developer App already has an existing Webhook target URL. Please delete it first or use a different Developer App.` | ||
); | ||
}; | ||
|
||
export const createWebhookTargetIfNoneExists = async (developerApiKey: string, hubspotAppId: number) => { | ||
const hubspotClient = new HubspotClient({ developerApiKey }); | ||
const exists = await checkWebhookTargetExists(hubspotClient, hubspotAppId); | ||
if (!exists) { | ||
await hubspotClient.webhooks.settingsApi.configure(hubspotAppId, { | ||
throttling: { | ||
period: 'SECONDLY', | ||
maxConcurrentRequests: 10, | ||
}, | ||
targetUrl: HUBSPOT_WEBHOOK_TARGET_URL, | ||
}); | ||
} | ||
}; | ||
|
||
export const deleteWebhookTargetIfExists = async (developerApiKey: string, hubspotAppId: number) => { | ||
const hubspotClient = new HubspotClient({ developerApiKey }); | ||
await hubspotClient.webhooks.settingsApi.clear(hubspotAppId); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.