Skip to content
Merged
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

91 changes: 90 additions & 1 deletion packages/v1-ready/hubspot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,93 @@

This is the API Module for hubspot that allows the [Frigg](https://friggframework.org) code to talk to the hubspot API.

Read more on the [Frigg documentation site](https://docs.friggframework.org/api-modules/list/hubspot
Read more on the [Frigg documentation site](https://docs.friggframework.org/api-modules/list/hubspot).

## Webhooks (Integration Extension)

HubSpot webhooks are **app-level**, not account-level: one HubSpot app has a single
webhook target URL, and every connected portal (account) fires events to that same
URL. A Frigg app, by contrast, runs many per-portal integration records behind one
deployment. Bridging the two means every integration that wants HubSpot webhooks has
to write the same plumbing — an HTTP receiver, signature verification, a portal-ID
lookup to find the right integration record, and a hand-off to a queue worker.

This module ships that plumbing once as a **Tier 3 Integration Extension** —
`hubspot.extensions.webhooks` — so an integration just plugs it in and writes the
business logic. The extension is a reusable bundle of `{ routes, events }` that the
Frigg framework merges into the consuming integration's definition. See the framework
[EXTENSIONS.md](https://github.com/friggframework/frigg/blob/next/packages/core/integrations/EXTENSIONS.md)
for the full Tier 3 contract.

### How the app-level / account-level split is handled

| Concern | Where it lives | Identity used |
|---|---|---|
| Signature verification | Extension receiver (`HUBSPOT_WEBHOOK_RECEIVED`) | App client secret (`HUBSPOT_CLIENT_SECRET`) |
| Portal → integration routing | `findIntegrationByEntityExternalId` via friggCommands | Inbound `portalId` from the payload |
| Per-account business logic | Your bound `HUBSPOT_WEBHOOK` handler | Per-portal OAuth credentials (loaded by the worker) |

At request time HubSpot POSTs the whole app's events to the receiver. The receiver
verifies the v3 signature, then resolves each event's `portalId` to the owning Frigg
integration and enqueues a per-event `HUBSPOT_WEBHOOK` job. The queue worker hydrates
that integration with its own per-portal credentials and runs your handler.

### Enabling it on an integration

Bind the extension on your integration's `static Definition.extensions` and map the
`HUBSPOT_WEBHOOK` event to a method on your class:

```javascript
const { IntegrationBase, createFriggCommands } = require('@friggframework/core');
const hubspot = require('@friggframework/api-module-hubspot');

class HubSpotIntegration extends IntegrationBase {
static Definition = {
name: 'hubspot',
modules: { hubspot: { definition: hubspot.Definition } },
extensions: {
hubspotWebhooks: {
extension: hubspot.extensions.webhooks,
handlers: { HUBSPOT_WEBHOOK: 'onHubSpotEvent' },
},
},
};

constructor(params) {
super(params);
// Required: the receiver resolves portalId → integration through commands.
this.commands = createFriggCommands({ integrationClass: HubSpotIntegration });
}

async onHubSpotEvent({ data }) {
// Pure business logic. Signature verification, portalId lookup, and
// queue dispatch are already done — `data.body` is the HubSpot event.
const { subscriptionType, objectId } = data.body;
if (subscriptionType === 'contact.creation') {
await this.syncContact(objectId);
}
}
}
```

The framework auto-mounts the receiver route at the integration's base path
(`POST /<integration-base>/webhooks`) — register that URL as the app's webhook target
in your HubSpot app settings.

### Configuration

| Env var | Purpose |
|---|---|
| `HUBSPOT_CLIENT_SECRET` | App client secret used to verify the `X-HubSpot-Signature-V3` header. The receiver rejects with `401` if it is unset or the signature does not match. |

### What the bundle contributes

- **Route:** `POST /webhooks` → `HUBSPOT_WEBHOOK_RECEIVED`
- **`HUBSPOT_WEBHOOK_RECEIVED`** — default receiver: verifies the signature, resolves
each event's `portalId`, and queues matched events. Events whose portal does not map
to any integration are skipped (HubSpot broadcasts every portal's events to the app).
Events are resolved and enqueued in parallel; if a `portalId` resolves ambiguously
(one external ID owned by multiple integrations) the whole batch is rejected rather
than risk cross-tenant routing.
- **`HUBSPOT_WEBHOOK`** — default no-op; override it via `binding.handlers.HUBSPOT_WEBHOOK`
to run your per-event logic.
105 changes: 105 additions & 0 deletions packages/v1-ready/hubspot/extensions/webhooks/handlers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
const { verifyHubSpotSignature } = require('./signature-verifier');
const { findIntegrationByPortalId } = require('./lookup');

/**
* Receiver handler for `POST /webhooks`.
*
* Verifies HubSpot's v3 signature using `process.env.HUBSPOT_CLIENT_SECRET`
* (the same env var the api-module already reads for OAuth), iterates the
* inbound batch, resolves each event's `portalId` to a Frigg integration via
* the platform-neutral reverse lookup, and enqueues a per-event
* `HUBSPOT_WEBHOOK` job for the matched integration. Events whose portal
* does not map to any integration are silently skipped (HubSpot sends events
* for the whole app, not per-account).
*
* Per the Tier 3 contract, this function is bound as a plain function on the
* integration instance — `this` is the IntegrationBase instance and exposes
* `commands.findIntegrationByEntityExternalId` and `queueWebhook`.
*
* @this {import('@friggframework/core').IntegrationBase}
* @param {Object} args
* @param {import('express').Request} args.req
* @param {import('express').Response} args.res
* @returns {Promise<void>}
*/
async function onHubSpotWebhookReceived({ req, res }) {
const verification = verifyHubSpotSignature({
req,
clientSecret: process.env.HUBSPOT_CLIENT_SECRET,
});
if (!verification.valid) {
console.warn(
`[hubspot-webhooks] rejecting webhook: ${verification.reason}`
);
res.status(401).json({ error: 'invalid signature' });
return;
}

const events = Array.isArray(req.body) ? req.body : [];

// Phase 1 — resolve every portalId in parallel before queueing anything.
// Two reasons for two-phase: (a) HubSpot batches can be large and each
// lookup is an independent DB round-trip; running them serially blows
// the HubSpot response budget; (b) if any lookup is ambiguous, the core
// command throws by design — we want that throw to land BEFORE any
// queueWebhook fires so we never leave the batch in a partial-enqueue
// state that HubSpot's retry would then duplicate.
const resolutions = await Promise.all(
events.map(async (evt, i) => {
const portalId = evt && evt.portalId;
if (portalId === undefined || portalId === null) {
console.warn(
`[hubspot-webhooks] event[${i}] missing portalId ` +
`(subscriptionType=${evt && evt.subscriptionType}); skipping`
);
return null;
}
const integrationId = await findIntegrationByPortalId(
this,
portalId
);
if (!integrationId) return null;
return { integrationId, evt };
})
);

// Phase 2 — enqueue matched events in parallel. Every match resolved
// cleanly above, so any failure here is genuinely SQS-side and should
// surface to HubSpot for retry.
const matches = resolutions.filter(Boolean);
await Promise.all(
matches.map(({ integrationId, evt }) =>
this.queueWebhook({
integrationId,
body: evt,
event: 'HUBSPOT_WEBHOOK',
})
)
);

res.status(200).json({
received: events.length,
queued: matches.length,
skipped: events.length - matches.length,
});
}

/**
* Default per-event handler. Integration consumers override this via
* `binding.handlers.HUBSPOT_WEBHOOK = 'methodName'`; the default is a no-op
* so a misconfigured binding fails predictably rather than crashing the
* queue worker.
*
* @this {import('@friggframework/core').IntegrationBase}
* @param {Object} args
* @param {Object} args.data
* @returns {Promise<void>}
*/
async function onHubSpotWebhook({ data }) {
// intentional no-op — override via binding.handlers.HUBSPOT_WEBHOOK
}

module.exports = {
onHubSpotWebhookReceived,
onHubSpotWebhook,
};
39 changes: 39 additions & 0 deletions packages/v1-ready/hubspot/extensions/webhooks/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const {
onHubSpotWebhookReceived,
onHubSpotWebhook,
} = require('./handlers');

/**
* HubSpot Webhooks — Tier 3 Integration Extension bundle.
*
* Contributes a single receiver route (`POST /webhooks`) plus two events:
*
* HUBSPOT_WEBHOOK_RECEIVED — bound to the route; verifies signature,
* resolves portalId → integrationId, queues
* one HUBSPOT_WEBHOOK per matched event.
* HUBSPOT_WEBHOOK — default no-op; integrations override via
* `binding.handlers.HUBSPOT_WEBHOOK`.
*
* See the binding contract at:
* https://github.com/friggframework/frigg/blob/next/packages/core/integrations/EXTENSIONS.md
*/
module.exports = {
name: 'hubspot-webhooks',
routes: [
{
path: '/webhooks',
method: 'POST',
event: 'HUBSPOT_WEBHOOK_RECEIVED',
},
],
events: {
HUBSPOT_WEBHOOK_RECEIVED: {
type: 'LIFE_CYCLE_EVENT',
handler: onHubSpotWebhookReceived,
},
HUBSPOT_WEBHOOK: {
type: 'LIFE_CYCLE_EVENT',
handler: onHubSpotWebhook,
},
},
};
48 changes: 48 additions & 0 deletions packages/v1-ready/hubspot/extensions/webhooks/lookup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const { Definition } = require('../../definition');

/**
* HubSpot-vocabulary wrapper around the platform-neutral friggCommand
* `findIntegrationByEntityExternalId`. The command is the canonical access
* point for cross-cutting reverse lookups; this wrapper exists so the
* api-module's webhook code reads in HubSpot terms ("portalId") rather than
* the generic "externalId / moduleName" pair.
*
* Lives inside the api-module per the Tier 3 contract: "portalId" is HubSpot
* vocabulary and does not belong in core. The wrapper takes the integration
* instance explicitly so it can be called from extension default handlers
* (where `this` is the integration) or from worker code that holds a handle
* to an instance.
*
* The module name is sourced from `definition.js` so a rename of the api
* module flows through automatically.
*
* Intentionally does not catch ambiguous-resolution errors — those signal a
* cross-tenant routing risk that callers should not paper over.
*
* @param {Object} integration - An IntegrationBase instance with `commands`
* wired (see `createFriggCommands` in @friggframework/core).
* @param {string|number} portalId - The HubSpot portal/hub ID from the
* webhook event.
* @returns {Promise<string|null>} The Frigg integration id, or null if no
* matching integration exists for that portal.
*/
async function findIntegrationByPortalId(integration, portalId) {
if (
!integration ||
!integration.commands ||
typeof integration.commands.findIntegrationByEntityExternalId !==
'function'
) {
throw new Error(
'findIntegrationByPortalId: integration instance must expose commands.findIntegrationByEntityExternalId() — wire up createFriggCommands in your integration constructor.'
);
}
return integration.commands.findIntegrationByEntityExternalId(
portalId,
Definition.moduleName
);
}

module.exports = {
findIntegrationByPortalId,
};
Loading
Loading